From abc1b065826054f22be6d71da86619ed83992065 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:02:09 +0800 Subject: [PATCH] feat: publish qualified native Schwab cash and account type facts Co-Authored-By: Codex --- .github/workflows/account-facts-sync.yml | 1 + .../workflows/execution-report-heartbeat.yml | 1 + .github/workflows/sync-cloud-run-env.yml | 22 +++ application/account_observation.py | 69 ++++++- main.py | 9 +- pyproject.toml | 4 +- qsl.toml | 2 +- scripts/publish_account_facts_from_reports.py | 44 ++++- scripts/verify_cloud_run_no_traffic_deploy.py | 98 +++++++++- tests/test_account_observation.py | 65 ++++++- .../test_deployed_runtime_target_admission.py | 157 +++++++++++++++ ...test_publish_account_facts_from_reports.py | 180 ++++++++++++++++++ tests/test_runtime_broker_adapters.py | 34 +++- tests/test_safe_no_traffic_deploy_workflow.sh | 5 + tests/test_sync_cloud_run_env_workflow.sh | 7 + uv.lock | 6 +- 16 files changed, 688 insertions(+), 16 deletions(-) diff --git a/.github/workflows/account-facts-sync.yml b/.github/workflows/account-facts-sync.yml index dcdbed9..62bc377 100644 --- a/.github/workflows/account-facts-sync.yml +++ b/.github/workflows/account-facts-sync.yml @@ -52,6 +52,7 @@ jobs: EXPECTED_RUNTIME_REVISION: ${{ inputs.expected_runtime_revision }} ACCOUNT_FACTS_SYNC_URL: ${{ vars.ACCOUNT_FACTS_SYNC_URL }} SCHWAB_NET_ASSETS_CURRENCY: ${{ vars.SCHWAB_NET_ASSETS_CURRENCY }} + SCHWAB_CASH_CURRENCY: ${{ vars.SCHWAB_CASH_CURRENCY }} SCHWAB_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.SCHWAB_ACCOUNT_FACTS_TARGET_ID }} SCHWAB_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.SCHWAB_ACCOUNT_FACTS_REPORT_PREFIX }} SCHWAB_ACCOUNT_FACTS_SERVICE_NAME: ${{ secrets.SCHWAB_ACCOUNT_FACTS_SERVICE_NAME }} diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index a2d77b7..eda5ad4 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -121,6 +121,7 @@ jobs: env: ACCOUNT_FACTS_SYNC_URL: ${{ vars.ACCOUNT_FACTS_SYNC_URL }} SCHWAB_NET_ASSETS_CURRENCY: ${{ vars.SCHWAB_NET_ASSETS_CURRENCY }} + SCHWAB_CASH_CURRENCY: ${{ vars.SCHWAB_CASH_CURRENCY }} SCHWAB_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.SCHWAB_ACCOUNT_FACTS_TARGET_ID }} SCHWAB_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.SCHWAB_ACCOUNT_FACTS_REPORT_PREFIX }} SCHWAB_ACCOUNT_FACTS_SERVICE_NAME: ${{ secrets.SCHWAB_ACCOUNT_FACTS_SERVICE_NAME }} diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 2f65c43..a99050d 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -94,6 +94,7 @@ jobs: # control-plane variable while retaining a legacy-secret fallback during migration. RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }} SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }} + SCHWAB_CASH_CURRENCY: ${{ vars.SCHWAB_CASH_CURRENCY }} SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }} SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }} SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE }} @@ -344,6 +345,11 @@ jobs: exit 1 fi + if [ -n "${SCHWAB_CASH_CURRENCY:-}" ] && [ "${SCHWAB_CASH_CURRENCY}" != "USD" ]; then + echo "SCHWAB_CASH_CURRENCY must be USD when configured." >&2 + exit 1 + fi + - name: Authenticate to Google Cloud id: auth if: steps.config.outputs.enabled == 'true' @@ -365,11 +371,16 @@ jobs: DEPLOY_READBACK_FILE: ${{ runner.temp }}/cloud-run-no-traffic-baseline.json run: | set -euo pipefail + cash_currency_args=() + if [ -n "${SCHWAB_CASH_CURRENCY:-}" ]; then + cash_currency_args+=(--cash-currency="${SCHWAB_CASH_CURRENCY}") + fi python3 scripts/verify_cloud_run_no_traffic_deploy.py capture \ --project="${GCP_PROJECT_ID}" \ --region="${CLOUD_RUN_REGION}" \ --service="${CLOUD_RUN_SERVICE}" \ --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ + "${cash_currency_args[@]}" \ --output="${DEPLOY_READBACK_FILE}" - name: Verify deployed runtime target admission before traffic shift @@ -403,6 +414,11 @@ jobs: immutable_image="${image_repo}@${image_digest}" echo "image_digest=${image_digest}" >> "${GITHUB_OUTPUT}" + cash_deploy_env_args=() + if [ -n "${SCHWAB_CASH_CURRENCY:-}" ]; then + cash_deploy_env_args+=(--update-env-vars="SCHWAB_CASH_CURRENCY=${SCHWAB_CASH_CURRENCY}") + fi + gcloud run deploy "${CLOUD_RUN_SERVICE}" \ --project="${GCP_PROJECT_ID}" \ --region="${CLOUD_RUN_REGION}" \ @@ -418,6 +434,7 @@ jobs: --timeout=300s \ --labels="managed-by=github-actions,commit-sha=${GITHUB_SHA},github-run-id=${GITHUB_RUN_ID}" \ --no-traffic \ + "${cash_deploy_env_args[@]}" \ --quiet - name: Verify no-traffic deployment readback @@ -427,11 +444,16 @@ jobs: EXPECTED_IMAGE_DIGEST: ${{ steps.deploy.outputs.image_digest }} run: | set -euo pipefail + cash_currency_args=() + if [ -n "${SCHWAB_CASH_CURRENCY:-}" ]; then + cash_currency_args+=(--cash-currency="${SCHWAB_CASH_CURRENCY}") + fi python3 scripts/verify_cloud_run_no_traffic_deploy.py verify \ --project="${GCP_PROJECT_ID}" \ --region="${CLOUD_RUN_REGION}" \ --service="${CLOUD_RUN_SERVICE}" \ --scheduler-location="${CLOUD_SCHEDULER_LOCATION:-${CLOUD_RUN_REGION}}" \ + "${cash_currency_args[@]}" \ --before="${DEPLOY_READBACK_FILE}" \ --expected-sha="${EXPECTED_SHA}" \ --expected-image-digest="${EXPECTED_IMAGE_DIGEST}" diff --git a/application/account_observation.py b/application/account_observation.py index 26b0d09..2bce4dc 100644 --- a/application/account_observation.py +++ b/application/account_observation.py @@ -5,9 +5,14 @@ from collections.abc import Mapping from datetime import datetime, timezone from decimal import Decimal, InvalidOperation +import re from typing import Any +_ACCOUNT_TYPE_TOKEN = re.compile(r"[A-Za-z_]{1,32}\Z", re.ASCII) +_CASH_MONEY_TEXT = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?$") + + def expected_account_hash_from_selector(account_selector: Any) -> str | None: """Resolve only an explicit single-account selector; preserve legacy live lookup.""" @@ -44,10 +49,28 @@ def _money_text(value: Any) -> str | None: return format(amount, "f") +def _cash_money_text(value: Any) -> str | None: + """Accept the bounded decimal text contract used by account-facts cash rows.""" + + if not isinstance(value, str) or _CASH_MONEY_TEXT.fullmatch(value) is None: + return None + whole, _, fraction = value.lstrip("-").partition(".") + if len(whole) > 15 or len(fraction) > 8: + return None + try: + amount = Decimal(value) + except (InvalidOperation, TypeError, ValueError): + return None + if not amount.is_finite(): + return None + return value + + def build_account_observation( snapshot: Any, *, net_assets_currency: str | None = None, + cash_currency: str | None = None, ) -> dict[str, object] | None: """Project verified values without changing the snapshot or raising into execution.""" @@ -80,6 +103,12 @@ def build_account_observation( metadata.get("cash_available_for_withdrawal") ) + raw_cash_balance = metadata.get("broker_cash_balance") + raw_cash_balance_source = metadata.get("broker_cash_balance_source") + cash_balance = _cash_money_text(raw_cash_balance) + raw_account_type = metadata.get("broker_account_type") + raw_account_type_source = metadata.get("broker_account_type_source") + observation: dict[str, object] = { "account_hash": account_hash, "currency": None, @@ -95,10 +124,24 @@ def build_account_observation( "cashAvailableForWithdrawal" if available_for_withdrawal is not None else None ), } - return declare_net_assets_currency( + if cash_balance is not None and raw_cash_balance_source == "cashBalance": + observation["cash_balance"] = cash_balance + observation["cash_balance_source"] = "cashBalance" + if ( + isinstance(raw_account_type, str) + and _ACCOUNT_TYPE_TOKEN.fullmatch(raw_account_type) is not None + and raw_account_type_source == "securitiesAccount.type" + ): + observation["broker_account_type"] = raw_account_type + observation["broker_account_type_source"] = "securitiesAccount.type" + declared_observation = declare_net_assets_currency( observation, net_assets_currency=net_assets_currency, ) + return declare_cash_balance_currency( + declared_observation, + cash_currency=cash_currency, + ) except Exception: # Reporting must never change the outcome of an already-run strategy cycle. return None @@ -127,8 +170,32 @@ def declare_net_assets_currency( return projected +def declare_cash_balance_currency( + observation: Mapping[str, object] | None, + *, + cash_currency: str | None = None, +) -> dict[str, object] | None: + """Apply an independent owner-confirmed currency only to native cashBalance.""" + + if not isinstance(observation, Mapping): + return None + projected = dict(observation) + projected["cash_currency"] = None + projected["cash_currency_source"] = None + if ( + cash_currency != "USD" + or not isinstance(projected.get("cash_balance"), str) + or projected.get("cash_balance_source") != "cashBalance" + ): + return projected + projected["cash_currency"] = "USD" + projected["cash_currency_source"] = "owner_confirmed" + return projected + + __all__ = [ "build_account_observation", + "declare_cash_balance_currency", "declare_net_assets_currency", "expected_account_hash_from_selector", ] diff --git a/main.py b/main.py index 7af5dbb..ab2190b 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ ) from application.account_observation import ( build_account_observation, + declare_cash_balance_currency, declare_net_assets_currency, expected_account_hash_from_selector, ) @@ -878,7 +879,12 @@ def _handle_schwab_cycle(*, dry_run_only_override: bool | None = None, response_ net_assets_currency=os.getenv("SCHWAB_NET_ASSETS_CURRENCY"), ) if declared_observation is not None: - execution_summary["account_observation"] = declared_observation + cash_declared_observation = declare_cash_balance_currency( + declared_observation, + cash_currency=os.getenv("SCHWAB_CASH_CURRENCY"), + ) + if cash_declared_observation is not None: + execution_summary["account_observation"] = cash_declared_observation try: attach_cycle_execution_receipt(report, execution_result) except ValueError: @@ -1095,6 +1101,7 @@ def _handle_schwab_probe(*, response_body: str = "Probe OK"): account_observation = build_account_observation( snapshot, net_assets_currency=os.getenv("SCHWAB_NET_ASSETS_CURRENCY"), + cash_currency=os.getenv("SCHWAB_CASH_CURRENCY"), ) if account_observation is not None: summary["account_observation"] = account_observation diff --git a/pyproject.toml b/pyproject.toml index 82f391d..5bf1bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "google-cloud-storage", "google-auth", "numpy", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c7646a7168b3dafa763ef7751a182d23e8de7790", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@4a3943883cd6b5bbfe32a559e56a91b40a81b7ce", ] @@ -61,5 +61,5 @@ include = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c7646a7168b3dafa763ef7751a182d23e8de7790", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18", ] diff --git a/qsl.toml b/qsl.toml index 1c26964..5360917 100644 --- a/qsl.toml +++ b/qsl.toml @@ -5,7 +5,7 @@ upgrade_ring = "ring_d" allow_legacy = false [qsl.requires] -quant_platform_kit = "c7646a7168b3dafa763ef7751a182d23e8de7790" +quant_platform_kit = "2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18" us_equity_strategies = "4a3943883cd6b5bbfe32a559e56a91b40a81b7ce" [qsl.compat] diff --git a/scripts/publish_account_facts_from_reports.py b/scripts/publish_account_facts_from_reports.py index 32573d7..bbd55a5 100644 --- a/scripts/publish_account_facts_from_reports.py +++ b/scripts/publish_account_facts_from_reports.py @@ -33,6 +33,7 @@ FUTURE_SKEW = timedelta(minutes=5) _REPORT_PARTS = re.compile(r"^(\d{4}-\d{2})/(\d{8}T\d{6}Z)\.json$") _DECIMAL_TEXT = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?$") +_ACCOUNT_TYPE_TOKEN = re.compile(r"[A-Za-z_]{1,32}\Z", re.ASCII) _REVISION = re.compile(r"^[a-z][a-z0-9-]{0,62}$") @@ -143,6 +144,7 @@ def project_schwab_account_facts_history( expected_service_name: str, expected_runtime_revision: str, expected_target_id: str, + expected_cash_currency: str | None = None, now: datetime, ) -> dict[str, Any]: """Return the strict Schwab history body or an amount-free skip reason.""" @@ -200,6 +202,39 @@ def project_schwab_account_facts_history( if observed_at < current - MAX_AGE or observed_at > current + FUTURE_SKEW: raise _ProjectionError("observation_out_of_window") + cash: list[dict[str, str]] = [] + if ( + observation.get("cash_balance_source") == "cashBalance" + and observation.get("cash_currency") == "USD" + and observation.get("cash_currency_source") == "owner_confirmed" + and expected_cash_currency == "USD" + ): + try: + cash_balance = _money_text(observation.get("cash_balance")) + except _ProjectionError: + cash_balance = None + if cash_balance is not None: + cash = [ + { + "currency": "USD", + "cash_balance": cash_balance, + "source_tag": "cashBalance", + "currency_source": "owner_confirmed", + } + ] + + broker_account_type = None + raw_account_type = observation.get("broker_account_type") + if ( + isinstance(raw_account_type, str) + and _ACCOUNT_TYPE_TOKEN.fullmatch(raw_account_type) is not None + and observation.get("broker_account_type_source") == "securitiesAccount.type" + ): + broker_account_type = { + "value": raw_account_type, + "source_tag": "securitiesAccount.type", + } + report_month, report_run_id = _report_uri_parts(source_report_uri, report_prefix) if ( report_month != started_at.strftime("%Y-%m") @@ -209,7 +244,7 @@ def project_schwab_account_facts_history( if not isinstance(expected_target_id, str) or not expected_target_id.strip(): raise _ProjectionError("target_identity_unavailable") - return { + return_body = { "schema_version": HISTORY_SCHEMA, "snapshot_schema_version": SNAPSHOT_SCHEMA, "account_scope": ACCOUNT_SCOPE, @@ -231,9 +266,12 @@ def project_schwab_account_facts_history( "currency_source": "owner_confirmed", } ], - "cash": [], + "cash": cash, "account_hash": account_hash, } + if broker_account_type is not None: + return_body["broker_account_type"] = broker_account_type + return return_body except _ProjectionError as exc: return {"status": "skipped", "reason": exc.reason} @@ -524,6 +562,7 @@ def main(argv: list[str] | None = None) -> int: print("skipped:net_assets_currency_unconfirmed") return 2 target_id = os.getenv("SCHWAB_ACCOUNT_FACTS_TARGET_ID", "") + cash_currency = os.getenv("SCHWAB_CASH_CURRENCY") if not target_id.strip(): print("skipped:target_identity_unavailable") return 2 @@ -564,6 +603,7 @@ def main(argv: list[str] | None = None) -> int: expected_service_name=service_name, expected_runtime_revision=expected_revision, expected_target_id=target_id, + expected_cash_currency=cash_currency, now=now, ) if projected.get("status") == "skipped": diff --git a/scripts/verify_cloud_run_no_traffic_deploy.py b/scripts/verify_cloud_run_no_traffic_deploy.py index c407df2..9084d82 100644 --- a/scripts/verify_cloud_run_no_traffic_deploy.py +++ b/scripts/verify_cloud_run_no_traffic_deploy.py @@ -20,6 +20,12 @@ IAM_FORMAT = "json(bindings[].role,bindings[].members,bindings[].condition)" SCHEDULER_FORMAT = "json(name,state,schedule,timeZone,httpTarget.uri,httpTarget.oidcToken)" REVISION_FORMAT = "json(metadata.name,metadata.labels,spec.containers[].image)" +CASH_CURRENCY_FORMAT = ( + 'json(spec.template.spec.containers[].env.always().filter("name=SCHWAB_CASH_CURRENCY").map().extract(value))' +) +CASH_REVISION_FORMAT = ( + 'json(spec.containers[].env.always().filter("name=SCHWAB_CASH_CURRENCY").map().extract(value))' +) def _run_json(command: list[str]) -> object: @@ -40,6 +46,71 @@ def _digest(value: object) -> str: return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest() +def _cash_values(value: object) -> list[object]: + """Read only the explicitly projected non-secret cash-currency values.""" + if value is None: + return [] + if isinstance(value, list): + return [item for child in value for item in _cash_values(child)] + if isinstance(value, dict): + return [item for child in value.values() for item in _cash_values(child)] + return [value] + + +def _cash_currency(args: argparse.Namespace, *, revision: bool = False) -> str | None: + if not getattr(args, "cash_currency", None): + return None + if revision: + payload = _run_json([ + "gcloud", "run", "revisions", "describe", args.revision_name, + f"--project={args.project}", f"--region={args.region}", + f"--format={CASH_REVISION_FORMAT}", + ]) + else: + payload = _run_json([ + "gcloud", "run", "services", "describe", args.service, + f"--project={args.project}", f"--region={args.region}", + f"--format={CASH_CURRENCY_FORMAT}", + ]) + values = _cash_values(payload) + if not values: + return None + if len(values) != 1 or not isinstance(values[0], str): + raise RuntimeError("cash currency environment projection is ambiguous") + return values[0] + + +def _configuration_projection(spec: object, *, allow_cash_currency: bool) -> object: + if not allow_cash_currency or not isinstance(spec, dict): + return spec + # SERVICE_FORMAT returns only names and secret references, never plaintext + # environment values. Remove the single explicitly allowed env name before + # hashing so an absent-to-USD addition does not mask any other config drift. + projected = json.loads(_canonical(spec)) + template = projected.get("template") + template_spec = template.get("spec") if isinstance(template, dict) else None + containers = template_spec.get("containers") if isinstance(template_spec, dict) else None + if isinstance(containers, list): + cash_entries = [ + entry + for container in containers if isinstance(container, dict) + for entry in (container.get("env") if isinstance(container.get("env"), list) else []) + if isinstance(entry, dict) and entry.get("name") == "SCHWAB_CASH_CURRENCY" + ] + if len(cash_entries) > 1: + raise RuntimeError("cash currency environment entry is duplicated") + if cash_entries and "valueFrom" in cash_entries[0]: + raise RuntimeError("cash currency environment entry must not use a secret reference") + for container in containers: + if not isinstance(container, dict) or not isinstance(container.get("env"), list): + continue + container["env"] = [ + entry for entry in container["env"] + if not (isinstance(entry, dict) and entry.get("name") == "SCHWAB_CASH_CURRENCY") + ] + return projected + + def _active_traffic(traffic: object) -> list[dict[str, object]]: if not isinstance(traffic, list): raise RuntimeError("Cloud Run traffic readback returned a non-list payload") @@ -75,12 +146,18 @@ def _snapshot(args: argparse.Namespace) -> dict[str, object]: # Keep only digests in the on-runner baseline. Service-account identities, # endpoint URIs, and secret-reference names are needed for comparison but # must not be persisted or printed by this verification helper. + cash_currency = _cash_currency(args) + if getattr(args, "cash_currency", None) and cash_currency not in (None, args.cash_currency): + raise RuntimeError("cash currency baseline does not match the approved declaration") return { # A no-traffic revision is allowed to appear as a zero-percent status # entry. Compare only effective traffic, otherwise a correct deploy # would fail its own readback merely because the new revision exists. "traffic": _digest(_active_traffic(status.get("traffic"))), - "configuration": _digest(service.get("spec")), + "configuration": _digest(_configuration_projection( + service.get("spec"), allow_cash_currency=bool(getattr(args, "cash_currency", None)) + )), + **({"cash_currency": cash_currency} if getattr(args, "cash_currency", None) else {}), "iam": _digest(iam), "scheduler": _digest(scheduler), } @@ -125,10 +202,26 @@ def _verify(args: argparse.Namespace) -> None: raise RuntimeError("created revision commit SHA does not match expected SHA") if f"@{args.expected_image_digest}" not in str(image): raise RuntimeError("created revision image digest does not match the pushed image") - print( + if getattr(args, "cash_currency", None): + before_currency = before.get("cash_currency") + if before_currency not in (None, args.cash_currency): + raise RuntimeError("deployment baseline cash currency is not an allowed value") + # Re-read only the explicitly named, non-secret value from the service + # and created revision. Neither full env values nor the projection are + # written into the baseline or emitted in diagnostics. + revision_name = metadata.get("name") + if not isinstance(revision_name, str) or not revision_name: + raise RuntimeError("created revision name is missing") + args.revision_name = revision_name + if after.get("cash_currency") != args.cash_currency or _cash_currency(args, revision=True) != args.cash_currency: + raise RuntimeError("cash currency declaration did not reach the created revision") + message = ( "Verified no-traffic deployment: commit SHA and image digest match; " "traffic, scheduler, IAM, and configuration digests are unchanged." ) + if getattr(args, "cash_currency", None): + message += " The explicitly approved cash currency matches." + print(message) def main() -> int: @@ -140,6 +233,7 @@ def main() -> int: command.add_argument("--region", required=True) command.add_argument("--service", required=True) command.add_argument("--scheduler-location", required=True) + command.add_argument("--cash-currency", choices=("USD",)) subparsers.choices["capture"].add_argument("--output", required=True, type=Path) verify = subparsers.choices["verify"] verify.add_argument("--before", required=True, type=Path) diff --git a/tests/test_account_observation.py b/tests/test_account_observation.py index 41e9da0..a0010f7 100644 --- a/tests/test_account_observation.py +++ b/tests/test_account_observation.py @@ -18,10 +18,16 @@ def test_owner_declared_usd_applies_only_to_verified_nlv_and_preserves_observati "total_equity_source": "broker_liquidation_value", "broker_cash_available_for_trading": 900.0, "cash_available_for_withdrawal": 750.0, + "broker_cash_balance": "123.4500", + "broker_cash_balance_source": "cashBalance", + "broker_account_type": "PROVIDER_UNKNOWN", + "broker_account_type_source": "securitiesAccount.type", }, ) - observation = build_account_observation(snapshot, net_assets_currency="USD") + observation = build_account_observation( + snapshot, net_assets_currency="USD", cash_currency="USD" + ) assert observation is not None assert observation["account_hash"] == "synthetic-account-id" @@ -33,7 +39,12 @@ def test_owner_declared_usd_applies_only_to_verified_nlv_and_preserves_observati assert observation["currency"] is None assert observation["available_for_trading"] == "900.0" assert observation["available_for_withdrawal"] == "750.0" - assert "cash_balance" not in observation + assert observation["cash_balance"] == "123.4500" + assert observation["cash_balance_source"] == "cashBalance" + assert observation["cash_currency"] == "USD" + assert observation["cash_currency_source"] == "owner_confirmed" + assert observation["broker_account_type"] == "PROVIDER_UNKNOWN" + assert observation["broker_account_type_source"] == "securitiesAccount.type" assert "cash" not in observation assert snapshot.as_of is as_of assert snapshot.total_equity == 12345.67 @@ -65,3 +76,53 @@ def test_cash_availability_does_not_create_cash_balance_or_zero_net_assets() -> assert observation["available_for_trading"] == "500.0" assert "cash_balance" not in observation assert "cash" not in observation + + +def test_nlv_currency_does_not_confirm_cash_and_invalid_optional_facts_are_omitted() -> None: + snapshot = SimpleNamespace( + as_of=datetime(2026, 9, 30, 10, tzinfo=timezone.utc), + total_equity=123.45, + buying_power=500.0, + cash_balance=0.0, + positions=(), + metadata={ + "account_hash": "synthetic-account-id", + "total_equity_source": "broker_liquidation_value", + "broker_cash_balance": "1234567890123456.123456789", + "broker_cash_balance_source": "cashBalance", + "broker_account_type": "not a token", + "broker_account_type_source": "securitiesAccount.type", + }, + ) + + observation = build_account_observation(snapshot, net_assets_currency="USD") + + assert observation is not None + assert observation["net_assets_currency"] == "USD" + assert observation["net_assets_currency_source"] == "owner_confirmed" + assert "cash_balance" not in observation + assert observation["cash_currency"] is None + assert observation["cash_currency_source"] is None + assert "broker_account_type" not in observation + assert "broker_account_type_source" not in observation + + +def test_cash_currency_confirmation_requires_exact_usd_and_native_cash_fact() -> None: + snapshot = SimpleNamespace( + as_of=datetime(2026, 9, 30, 10, tzinfo=timezone.utc), + total_equity=123.45, + buying_power=500.0, + cash_balance=0.0, + positions=(), + metadata={ + "account_hash": "synthetic-account-id", + "broker_cash_balance": "0", + "broker_cash_balance_source": "cashBalance", + }, + ) + + for declared_currency, expected in ((None, None), ("USD ", None), ("EUR", None), ("USD", "USD")): + observation = build_account_observation(snapshot, cash_currency=declared_currency) + assert observation is not None + assert observation["cash_currency"] == expected + assert observation["cash_currency_source"] == ("owner_confirmed" if expected else None) diff --git a/tests/test_deployed_runtime_target_admission.py b/tests/test_deployed_runtime_target_admission.py index 942390f..abe849a 100644 --- a/tests/test_deployed_runtime_target_admission.py +++ b/tests/test_deployed_runtime_target_admission.py @@ -100,3 +100,160 @@ def projected(command): result = readback._snapshot(SimpleNamespace(service="synthetic", project="synthetic", region="synthetic", scheduler_location="synthetic")) assert result["configuration"] == readback._digest(service_spec) assert result["iam"] == readback._digest(policy) + + +@pytest.mark.parametrize( + ("baseline_currency", "service_currency", "revision_currency", "passes"), + [ + (None, "USD", "USD", True), + ("USD", "USD", "USD", True), + ("EUR", "USD", "USD", False), + (None, "EUR", "USD", False), + (None, None, "USD", False), + (None, "USD", "EUR", False), + (None, "USD", None, False), + ], +) +def test_explicit_cash_declaration_allows_only_absent_to_usd(baseline_currency, service_currency, revision_currency, passes, tmp_path, monkeypatch): + from types import SimpleNamespace + from scripts import verify_cloud_run_no_traffic_deploy as readback + + sha, digest = "a" * 40, "sha256:" + "b" * 64 + before = tmp_path / "before.json" + before.write_text(json.dumps({ + "traffic": "traffic", + "scheduler": "scheduler", + "iam": "iam", + "configuration": "configuration", + "cash_currency": baseline_currency, + })) + args = SimpleNamespace( + before=before, project="synthetic", region="synthetic", service="synthetic", + scheduler_location="synthetic", expected_sha=sha, + expected_image_digest=digest, cash_currency="USD", + ) + monkeypatch.setattr(readback, "_snapshot", lambda _args: { + "traffic": "traffic", "scheduler": "scheduler", "iam": "iam", + "configuration": "configuration", "cash_currency": service_currency, + }) + monkeypatch.setattr(readback, "_created_revision", lambda _args: { + "metadata": {"name": "synthetic-revision", "labels": {"commit-sha": sha}}, + "spec": {"containers": [{"image": "synthetic/image@" + digest}]}, + }) + monkeypatch.setattr(readback, "_cash_currency", lambda _args, revision=False: revision_currency if revision else service_currency) + monkeypatch.setattr(readback, "print", lambda *_args, **_kwargs: None, raising=False) + + if passes: + readback._verify(args) + else: + with pytest.raises(RuntimeError): + readback._verify(args) + + +def test_explicit_cash_declaration_does_not_mask_other_service_configuration(monkeypatch): + from types import SimpleNamespace + from scripts import verify_cloud_run_no_traffic_deploy as readback + + baseline = {"template": {"spec": {"containers": [{ + "resources": {"limits": {"cpu": "1"}}, "env": [{"name": "OTHER_SETTING"}], + }]}}} + cash_added = {"template": {"spec": {"containers": [{ + "resources": {"limits": {"cpu": "1"}}, "env": [ + {"name": "OTHER_SETTING"}, {"name": "SCHWAB_CASH_CURRENCY"}, + ], + }]}}} + assert readback._digest(readback._configuration_projection(baseline, allow_cash_currency=True)) == readback._digest( + readback._configuration_projection(cash_added, allow_cash_currency=True) + ) + after = {"template": {"spec": {"containers": [{ + "resources": {"limits": {"cpu": "2"}}, "env": [ + {"name": "OTHER_SETTING"}, {"name": "SCHWAB_CASH_CURRENCY"}, + ], + }]}}} + assert readback._digest(readback._configuration_projection(baseline, allow_cash_currency=True)) != readback._digest( + readback._configuration_projection(after, allow_cash_currency=True) + ) + after = {"template": {"spec": {"containers": [{ + "resources": {"limits": {"cpu": "1"}}, "env": [ + {"name": "SCHWAB_CASH_CURRENCY"}, {"name": "OTHER_SETTING_CHANGED"}, + ], + }]}}} + assert readback._digest(readback._configuration_projection(baseline, allow_cash_currency=True)) != readback._digest( + readback._configuration_projection(after, allow_cash_currency=True) + ) + assert readback._digest(readback._configuration_projection(baseline, allow_cash_currency=False)) != readback._digest( + readback._configuration_projection(after, allow_cash_currency=False) + ) + + +@pytest.mark.parametrize( + "cash_entries", + [ + [{"name": "SCHWAB_CASH_CURRENCY", "valueFrom": {"secretKeyRef": {"name": "synthetic", "key": "1"}}}], + [{"name": "SCHWAB_CASH_CURRENCY"}, {"name": "SCHWAB_CASH_CURRENCY"}], + ], +) +def test_explicit_cash_declaration_rejects_secret_or_duplicate_entry(cash_entries): + from scripts import verify_cloud_run_no_traffic_deploy as readback + + spec = {"template": {"spec": {"containers": [{"env": cash_entries}]}}} + with pytest.raises(RuntimeError, match="cash currency environment entry"): + readback._configuration_projection(spec, allow_cash_currency=True) + + +def test_no_cash_declaration_keeps_original_success_message(tmp_path, monkeypatch, capsys): + from types import SimpleNamespace + from scripts import verify_cloud_run_no_traffic_deploy as readback + + sha, digest = "a" * 40, "sha256:" + "b" * 64 + before = tmp_path / "before.json" + baseline = {key: "synthetic" for key in ("traffic", "configuration", "iam", "scheduler")} + before.write_text(json.dumps(baseline)) + args = SimpleNamespace( + before=before, project="synthetic", region="synthetic", service="synthetic", + scheduler_location="synthetic", expected_sha=sha, expected_image_digest=digest, + ) + monkeypatch.setattr(readback, "_snapshot", lambda _args: baseline) + monkeypatch.setattr(readback, "_created_revision", lambda _args: { + "metadata": {"labels": {"commit-sha": sha}}, + "spec": {"containers": [{"image": "synthetic/image@" + digest}]}, + }) + + readback._verify(args) + + output = capsys.readouterr().out + assert "traffic, scheduler, IAM, and configuration digests are unchanged." in output + assert "cash currency matches" not in output + + +@pytest.mark.parametrize( + ("revision", "payload", "expected_format"), + [ + (False, {"spec": {"template": {"spec": {"containers": [{"env": [["USD"]]}]}}}}, "service"), + (True, {"spec": {"containers": [{"env": [["USD"]]}]}}, "revision"), + (False, None, "service"), + (True, None, "revision"), + ], +) +def test_cash_currency_projection_is_targeted_and_never_persists_other_env_values(monkeypatch, revision, payload, expected_format): + from types import SimpleNamespace + from scripts import verify_cloud_run_no_traffic_deploy as readback + + commands = [] + + def run_json(command): + commands.append(command) + return payload + + monkeypatch.setattr(readback, "_run_json", run_json) + args = SimpleNamespace( + cash_currency="USD", service="synthetic", project="synthetic", region="synthetic", + revision_name="synthetic-revision", + ) + assert readback._cash_currency(args, revision=revision) == ("USD" if payload else None) + expected = readback.CASH_REVISION_FORMAT if expected_format == "revision" else readback.CASH_CURRENCY_FORMAT + assert commands[0][-1] == f"--format={expected}" + assert "env.always().filter(\"name=SCHWAB_CASH_CURRENCY\").map().extract(value)" in commands[0][-1] + assert "SYNTHETIC_OTHER" not in json.dumps(payload) + assert "PRIVATE" not in json.dumps(payload) + assert "env[].value" not in commands[0][-1] diff --git a/tests/test_publish_account_facts_from_reports.py b/tests/test_publish_account_facts_from_reports.py index a65c95d..a3a638f 100644 --- a/tests/test_publish_account_facts_from_reports.py +++ b/tests/test_publish_account_facts_from_reports.py @@ -74,6 +74,186 @@ def test_valid_same_cycle_observation_projects_with_native_time_and_empty_cash() assert body["cash"] == [] +def test_cash_and_raw_account_type_project_as_independent_same_report_facts(): + prefix = "gs://example-bucket/execution-reports/charles_schwab/soxl_soxx_trend_income/" + report = _valid_report() + report["summary"]["account_observation"].update( + { + "cash_balance": "123.4500", + "cash_balance_source": "cashBalance", + "cash_currency": "USD", + "cash_currency_source": "owner_confirmed", + "broker_account_type": "PROVIDER_UNKNOWN", + "broker_account_type_source": "securitiesAccount.type", + } + ) + + body = publisher.project_schwab_account_facts_history( + report, + source_report_uri=prefix + "2026-09/20260930T222000Z.json", + report_prefix=prefix, + expected_service_name="synthetic-service", + expected_runtime_revision="service-00007-abc", + expected_target_id="synthetic-target", + expected_cash_currency="USD", + now=datetime(2026, 10, 1, 1, 20, tzinfo=timezone.utc), + ) + + assert body["broker_reported_balances"] == [ + { + "currency": "USD", + "net_assets": "123.45", + "source_tag": "liquidationValue", + "currency_source": "owner_confirmed", + } + ] + assert body["cash"] == [ + { + "currency": "USD", + "cash_balance": "123.4500", + "source_tag": "cashBalance", + "currency_source": "owner_confirmed", + } + ] + assert body["broker_account_type"] == { + "value": "PROVIDER_UNKNOWN", + "source_tag": "securitiesAccount.type", + } + assert body["account_hash"] == "synthetic-account-hash" + assert body["source_binding"]["id"] == publisher._binding_id( + "synthetic-account-hash", "synthetic-service" + ) + + +def test_cash_requires_native_source_and_separate_exact_usd_confirmation(): + prefix = "gs://example-bucket/execution-reports/charles_schwab/soxl_soxx_trend_income/" + uri = prefix + "2026-09/20260930T222000Z.json" + cases = ( + {"cash_balance": "1.25", "cash_balance_source": "cashBalance"}, + { + "cash_balance": "1.25", + "cash_balance_source": "cashBalance", + "cash_currency": "USD", + }, + { + "cash_balance": "1.25", + "cash_balance_source": "cashBalance", + "cash_currency": "USD", + "cash_currency_source": "owner_confirmed_wrongly", + }, + { + "cash_balance": "1.25", + "cash_balance_source": "cashAvailableForTrading", + "cash_currency": "USD", + "cash_currency_source": "owner_confirmed", + }, + { + "cash_balance": "1234567890123456.123456789", + "cash_balance_source": "cashBalance", + "cash_currency": "USD", + "cash_currency_source": "owner_confirmed", + }, + { + "cash_balance": "1e999999999", + "cash_balance_source": "cashBalance", + "cash_currency": "USD", + "cash_currency_source": "owner_confirmed", + }, + ) + for additions in cases: + report = _valid_report() + report["summary"]["account_observation"].update(additions) + body = publisher.project_schwab_account_facts_history( + report, + source_report_uri=uri, + report_prefix=prefix, + expected_service_name="synthetic-service", + expected_runtime_revision="service-00007-abc", + expected_target_id="synthetic-target", + now=datetime(2026, 10, 1, 1, 20, tzinfo=timezone.utc), + ) + assert body["cash"] == [] + assert body["broker_reported_balances"][0]["net_assets"] == "123.45" + + for configured_currency in (None, "EUR"): + report = _valid_report() + report["summary"]["account_observation"].update( + { + "cash_balance": "1.25", + "cash_balance_source": "cashBalance", + "cash_currency": "USD", + "cash_currency_source": "owner_confirmed", + } + ) + body = publisher.project_schwab_account_facts_history( + report, + source_report_uri=uri, + report_prefix=prefix, + expected_service_name="synthetic-service", + expected_runtime_revision="service-00007-abc", + expected_target_id="synthetic-target", + expected_cash_currency=configured_currency, + now=datetime(2026, 10, 1, 1, 20, tzinfo=timezone.utc), + ) + assert body["cash"] == [] + + +def test_optional_raw_type_is_validated_but_legacy_report_remains_accepted(): + prefix = "gs://example-bucket/execution-reports/charles_schwab/soxl_soxx_trend_income/" + uri = prefix + "2026-09/20260930T222000Z.json" + for token in ("CASH", "MARGIN", "PROVIDER_UNKNOWN"): + report = _valid_report() + report["summary"]["account_observation"].update( + { + "broker_account_type": token, + "broker_account_type_source": "securitiesAccount.type", + } + ) + body = publisher.project_schwab_account_facts_history( + report, + source_report_uri=uri, + report_prefix=prefix, + expected_service_name="synthetic-service", + expected_runtime_revision="service-00007-abc", + expected_target_id="synthetic-target", + now=datetime(2026, 10, 1, 1, 20, tzinfo=timezone.utc), + ) + assert body["broker_account_type"] == { + "value": token, + "source_tag": "securitiesAccount.type", + } + + for token, source in (("bad token", "securitiesAccount.type"), ("MARGIN", "wrong_source")): + report = _valid_report() + report["summary"]["account_observation"].update( + {"broker_account_type": token, "broker_account_type_source": source} + ) + body = publisher.project_schwab_account_facts_history( + report, + source_report_uri=uri, + report_prefix=prefix, + expected_service_name="synthetic-service", + expected_runtime_revision="service-00007-abc", + expected_target_id="synthetic-target", + now=datetime(2026, 10, 1, 1, 20, tzinfo=timezone.utc), + ) + assert "status" not in body + assert "broker_account_type" not in body + + legacy = _valid_report() + body = publisher.project_schwab_account_facts_history( + legacy, + source_report_uri=uri, + report_prefix=prefix, + expected_service_name="synthetic-service", + expected_runtime_revision="service-00007-abc", + expected_target_id="synthetic-target", + now=datetime(2026, 10, 1, 1, 20, tzinfo=timezone.utc), + ) + assert "broker_account_type" not in body + assert body["cash"] == [] + + def test_stale_snapshot_is_rejected_without_relabeling_time(): prefix = "gs://example-bucket/execution-reports/charles_schwab/soxl_soxx_trend_income/" uri = prefix + "2026-09/20260929T222000Z.json" diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index 45d1c95..ee4d663 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -16,7 +16,7 @@ def _candle(ts: datetime, close: float) -> dict[str, float]: return {"datetime": int(ts.timestamp() * 1000), "close": close} -def _balance_adapters(balances): +def _balance_adapters(balances, *, account_type=None, cash_balance=None): calls = [] submitted = [] @@ -29,7 +29,13 @@ def account_numbers(): def account(_account_hash, *, fields): calls.append("account") - return response({"securitiesAccount": {"currentBalances": balances, "positions": []}}) + account_balances = dict(balances) + if cash_balance is not None: + account_balances["cashBalance"] = cash_balance + account_payload = {"currentBalances": account_balances, "positions": []} + if account_type is not None: + account_payload["type"] = account_type + return response({"securitiesAccount": account_payload}) client = SimpleNamespace(get_account_numbers=account_numbers, get_account=account) adapters = build_runtime_broker_adapters( @@ -42,6 +48,30 @@ def account(_account_hash, *, fields): return adapters, client, calls, submitted +def test_locked_qpk_preserves_native_schwab_facts_from_one_account_response(): + adapters, client, calls, submitted = _balance_adapters( + { + "cashAvailableForTrading": 1000.0, + "buyingPower": 5000.0, + "liquidationValue": 12000.0, + }, + account_type="PROVIDER_UNKNOWN", + cash_balance="1234.5600", + ) + + snapshot = adapters.fetch_managed_snapshot(client) + + assert calls == ["account_numbers", "account"] + assert submitted == [] + assert snapshot.metadata["broker_account_type"] == "PROVIDER_UNKNOWN" + assert snapshot.metadata["broker_account_type_source"] == "securitiesAccount.type" + assert snapshot.metadata["broker_cash_balance"] == "1234.5600" + assert snapshot.metadata["broker_cash_balance_source"] == "cashBalance" + assert snapshot.cash_balance == 1000.0 + assert snapshot.buying_power == 5000.0 + assert snapshot.total_equity == 12000.0 + + @pytest.mark.parametrize("field", ["cashAvailableForTrading", "cashAvailableForWithdrawal", "liquidationValue"]) @pytest.mark.parametrize("value", [None, True, "invalid-balance", "NaN", "inf", "1e1000"]) def test_invalid_broker_balance_stops_rebalance_without_retry_or_submit(monkeypatch, field, value): diff --git a/tests/test_safe_no_traffic_deploy_workflow.sh b/tests/test_safe_no_traffic_deploy_workflow.sh index 73da12e..1d03169 100644 --- a/tests/test_safe_no_traffic_deploy_workflow.sh +++ b/tests/test_safe_no_traffic_deploy_workflow.sh @@ -35,6 +35,8 @@ grep -Fq 'Capture no-traffic deployment baseline' "$workflow" grep -Fq 'Verify no-traffic deployment readback' "$workflow" grep -Fq 'scripts/verify_cloud_run_no_traffic_deploy.py capture' "$workflow" grep -Fq 'scripts/verify_cloud_run_no_traffic_deploy.py verify' "$workflow" +grep -Fq 'cash_currency_args+=(--cash-currency="${SCHWAB_CASH_CURRENCY}")' "$workflow" +test "$(grep -Fc '"${cash_currency_args[@]}"' "$workflow")" -eq 2 grep -Fq -- '--expected-image-digest="${EXPECTED_IMAGE_DIGEST}"' "$workflow" grep -Fq 'image_summary.digest' "$workflow" @@ -42,6 +44,9 @@ grep -Fq 'for key in ("traffic", "scheduler", "iam", "configuration"):' "$readba grep -Fq 'raise RuntimeError(f"{key} changed during no-traffic deployment")' "$readback" grep -Fq 'Compare only effective traffic' "$readback" grep -Fq 'if percent > 0:' "$readback" +grep -Fq 'json(spec.template.spec.containers[].env.always().filter("name=SCHWAB_CASH_CURRENCY").map().extract(value))' "$readback" +grep -Fq 'json(spec.containers[].env.always().filter("name=SCHWAB_CASH_CURRENCY").map().extract(value))' "$readback" +grep -Fq 'deployment baseline cash currency is not an allowed value' "$readback" if grep -Fq 'secrets versions access' "$readback" || grep -Fq 'containers.env.value,' "$readback"; then echo "readback must not access Secret Manager values or plaintext environment values" >&2 exit 1 diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 6a69568..55cd6e1 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -53,6 +53,7 @@ grep -Fq 'SCHWAB_API_KEY_SECRET_NAME: ${{ vars.SCHWAB_API_KEY_SECRET_NAME }}' "$ grep -Fq 'SCHWAB_APP_SECRET_SECRET_NAME: ${{ vars.SCHWAB_APP_SECRET_SECRET_NAME }}' "$workflow_file" grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON || secrets.RUNTIME_TARGET_JSON }}' "$workflow_file" grep -Fq 'SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }}' "$workflow_file" +grep -Fq 'SCHWAB_CASH_CURRENCY: ${{ vars.SCHWAB_CASH_CURRENCY }}' "$workflow_file" grep -Fq 'SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }}' "$workflow_file" grep -Fq 'SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }}' "$workflow_file" grep -Fq 'SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_FALLBACK_MODE }}' "$workflow_file" @@ -209,6 +210,12 @@ grep -Fq -- '--concurrency=1' "$workflow_file" deploy_block="$(sed -n '/gcloud run deploy "${CLOUD_RUN_SERVICE}"/,/--quiet/p' "$workflow_file")" grep -Fq -- '--ingress=internal' <<<"$deploy_block" grep -Fq -- '--no-allow-unauthenticated' <<<"$deploy_block" +grep -Fq '"${cash_deploy_env_args[@]}"' <<<"$deploy_block" +grep -Fq 'cash_deploy_env_args+=(--update-env-vars="SCHWAB_CASH_CURRENCY=${SCHWAB_CASH_CURRENCY}")' "$workflow_file" +grep -Fq 'if [ -n "${SCHWAB_CASH_CURRENCY:-}" ] && [ "${SCHWAB_CASH_CURRENCY}" != "USD" ]; then' "$workflow_file" +cash_validation_line="$(grep -n -F 'SCHWAB_CASH_CURRENCY must be USD when configured.' "$workflow_file" | head -1 | cut -d: -f1)" +auth_line="$(grep -n -F 'name: Authenticate to Google Cloud' "$workflow_file" | head -1 | cut -d: -f1)" +test "${cash_validation_line}" -lt "${auth_line}" grep -Fq '"--workers", "1", "--threads", "1"' "$dockerfile" grep -Fxq 'threads = 1' "$gunicorn_config" grep -Fq 'python3 scripts/reconcile_cloud_runtime.py reconcile-traffic' "$workflow_file" diff --git a/uv.lock b/uv.lock index 3b87abc..e99f447 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=c7646a7168b3dafa763ef7751a182d23e8de7790" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18" }] [[package]] name = "anyio" @@ -186,7 +186,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytz" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=c7646a7168b3dafa763ef7751a182d23e8de7790" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "schwab-py" }, @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "1.0.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=c7646a7168b3dafa763ef7751a182d23e8de7790#c7646a7168b3dafa763ef7751a182d23e8de7790" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18#2dc0b26ad4f1255b7ba44c3fca49fa52036b1a18" } [[package]] name = "requests"