From 6047a54a49a29c1a87c71be8482f85dc00ac3fb9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:37:06 +0800 Subject: [PATCH 1/3] Add platform cash reserve policy --- .env.example | 2 + .github/workflows/sync-cloud-run-env.yml | 4 ++ README.md | 4 ++ application/rebalance_service.py | 21 ++++++- decision_mapper.py | 50 ++++++++++++++++ runtime_config_support.py | 28 +++++++++ tests/test_decision_mapper.py | 72 ++++++++++++++++++++++++ tests/test_runtime_config_support.py | 41 +++++++++++++- 8 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 tests/test_decision_mapper.py diff --git a/.env.example b/.env.example index e100f33..87455bc 100644 --- a/.env.example +++ b/.env.example @@ -39,5 +39,7 @@ FIRSTRADE_SESSION_CHECK_INCLUDE_POSITIONS=false FIRSTRADE_RUN_STRATEGY_ON_HTTP=false FIRSTRADE_LIVE_ORDER_ACK=false FIRSTRADE_MAX_ORDER_NOTIONAL_USD= +FIRSTRADE_MIN_RESERVED_CASH_USD=0 +FIRSTRADE_RESERVED_CASH_RATIO=0 FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000 FIRSTRADE_SMOKE_SYMBOL=SPY diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 1636c92..4bc7d9a 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -46,6 +46,8 @@ jobs: FIRSTRADE_RUN_STRATEGY_ON_HTTP: ${{ vars.FIRSTRADE_RUN_STRATEGY_ON_HTTP }} FIRSTRADE_LIVE_ORDER_ACK: ${{ vars.FIRSTRADE_LIVE_ORDER_ACK }} FIRSTRADE_MAX_ORDER_NOTIONAL_USD: ${{ vars.FIRSTRADE_MAX_ORDER_NOTIONAL_USD }} + FIRSTRADE_MIN_RESERVED_CASH_USD: ${{ vars.FIRSTRADE_MIN_RESERVED_CASH_USD }} + FIRSTRADE_RESERVED_CASH_RATIO: ${{ vars.FIRSTRADE_RESERVED_CASH_RATIO }} FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} FIRSTRADE_SMOKE_SYMBOL: ${{ vars.FIRSTRADE_SMOKE_SYMBOL }} FIRSTRADE_FEATURE_SNAPSHOT_PATH: ${{ vars.FIRSTRADE_FEATURE_SNAPSHOT_PATH }} @@ -414,6 +416,8 @@ jobs: add_optional_env FIRSTRADE_RUN_STRATEGY_ON_HTTP add_optional_env FIRSTRADE_LIVE_ORDER_ACK add_optional_env FIRSTRADE_MAX_ORDER_NOTIONAL_USD + add_optional_env FIRSTRADE_MIN_RESERVED_CASH_USD + add_optional_env FIRSTRADE_RESERVED_CASH_RATIO add_optional_env FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD add_optional_env FIRSTRADE_SMOKE_SYMBOL add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_PATH diff --git a/README.md b/README.md index 10a1bbb..acea97d 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ commit credentials. | `FIRSTRADE_RUN_STRATEGY_ON_HTTP` | Optional | Must be `true` before `/run` performs strategy evaluation and order routing | | `FIRSTRADE_LIVE_ORDER_ACK` | Optional | Must be `true` before `/run` can submit live orders | | `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` | Optional | Optional single-order cap for strategy-generated orders. Unset means no platform-side notional cap | +| `FIRSTRADE_MIN_RESERVED_CASH_USD` | Optional | Platform-level minimum cash reserve in USD. Defaults to `0`; the effective reserve is the max of this floor, `FIRSTRADE_RESERVED_CASH_RATIO * total equity`, and any strategy-provided reserve. | +| `FIRSTRADE_RESERVED_CASH_RATIO` | Optional | Platform-level minimum cash reserve ratio in `[0,1]`. Defaults to `0`; it can raise but not lower a strategy-provided reserve. | | `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` | Optional | Safe-haven/cash-sweep target values below this USD amount are kept as cash instead of buying BOXX/BIL. Default `1000`. | ## Local Validation @@ -176,6 +178,7 @@ all of these gates: - `FIRSTRADE_ENABLE_LIVE_TRADING=true` - `FIRSTRADE_LIVE_ORDER_ACK=true` - order value at or below `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` when that optional cap is set +- `FIRSTRADE_MIN_RESERVED_CASH_USD` / `FIRSTRADE_RESERVED_CASH_RATIO` may set a platform-level minimum cash reserve; defaults are `0`, and the effective reserve is the max of platform floor, platform ratio, and strategy reserve The strategy execution service uses whole-share limit orders for generated strategy orders. If the notional cap is below the current price of a target @@ -324,6 +327,7 @@ HTTP 策略闭环实盘还必须额外满足: - `FIRSTRADE_DRY_RUN_ONLY=false` - `FIRSTRADE_LIVE_ORDER_ACK=true` - 如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD`,单笔金额不超过该上限 +- `FIRSTRADE_MIN_RESERVED_CASH_USD` / `FIRSTRADE_RESERVED_CASH_RATIO` 可设置平台级最低预留现金;默认都是 `0`,实际预留取平台下限、平台比例和策略预留中的最大值 - `BOXX`/`BIL` 等避险现金替代标的目标金额低于 `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` 时保留现金,默认门槛 `1000` USD 策略闭环生成的是整数股限价单。如果设置了 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 28a81da..525f50c 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -162,6 +162,22 @@ def publish_log(text: str) -> None: return True +def _runtime_metadata_with_execution_policy( + metadata: Mapping[str, Any] | None, + *, + settings: PlatformRuntimeSettings, +) -> dict[str, Any]: + runtime_metadata = dict(metadata or {}) + runtime_metadata.setdefault( + "firstrade_execution_policy", + { + "reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0), + "reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0), + }, + ) + return runtime_metadata + + def run_strategy_cycle( *, runtime_settings: PlatformRuntimeSettings | None = None, @@ -221,7 +237,10 @@ def run_strategy_cycle( evaluation.decision, snapshot=snapshot, strategy_profile=settings.strategy_profile, - runtime_metadata=getattr(evaluation, "metadata", None), + runtime_metadata=_runtime_metadata_with_execution_policy( + getattr(evaluation, "metadata", None), + settings=settings, + ), ) plan = substitute_small_safe_haven_targets_with_cash( plan, diff --git a/decision_mapper.py b/decision_mapper.py index cf98ca0..59f9144 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import replace from typing import Any from quant_platform_kit.strategy_contracts import ( @@ -34,6 +35,50 @@ def _default_threshold_value(total_equity: float) -> float: return max(_DEFAULT_MIN_TRADE_FLOOR, float(total_equity) * _DEFAULT_REBALANCE_THRESHOLD_RATIO) +def _resolve_platform_reserved_cash( + *, + total_equity: float, + runtime_metadata: Mapping[str, Any] | None, +) -> float: + raw_policy = (runtime_metadata or {}).get("firstrade_execution_policy") + if not isinstance(raw_policy, Mapping): + return 0.0 + reserved_cash_floor_usd = max(0.0, float(raw_policy.get("reserved_cash_floor_usd", 0.0) or 0.0)) + reserved_cash_ratio = float(raw_policy.get("reserved_cash_ratio", 0.0) or 0.0) + reserved_cash_ratio = max(0.0, min(1.0, reserved_cash_ratio)) + return max(reserved_cash_floor_usd, max(0.0, float(total_equity)) * reserved_cash_ratio) + + +def _apply_reserved_cash_policy( + annotations: ValueTargetExecutionAnnotations, + *, + portfolio_inputs, + runtime_metadata: Mapping[str, Any] | None, +) -> ValueTargetExecutionAnnotations: + reserved_cash = max( + float(annotations.reserved_cash or 0.0), + _resolve_platform_reserved_cash( + total_equity=float(portfolio_inputs.total_equity), + runtime_metadata=runtime_metadata, + ), + ) + base_investable_cash = annotations.investable_cash + if base_investable_cash is None: + base_investable_cash = max( + 0.0, + float(portfolio_inputs.liquid_cash) - float(annotations.reserved_cash or 0.0), + ) + investable_cash = min( + max(0.0, float(base_investable_cash)), + max(0.0, float(portfolio_inputs.liquid_cash) - reserved_cash), + ) + return replace( + annotations, + reserved_cash=reserved_cash, + investable_cash=investable_cash, + ) + + def _build_hold_current_value_decision(portfolio_inputs, *, diagnostics: Mapping[str, Any]) -> StrategyDecision: positions = [] for symbol, market_value in sorted(portfolio_inputs.market_values.items()): @@ -210,6 +255,11 @@ def map_strategy_decision_to_plan( normalized_decision, portfolio_inputs=portfolio_inputs, ) + annotations = _apply_reserved_cash_policy( + annotations, + portfolio_inputs=portfolio_inputs, + runtime_metadata=runtime_metadata, + ) plan = build_value_target_runtime_plan( normalized_decision, strategy_profile=canonical_profile, diff --git a/runtime_config_support.py b/runtime_config_support.py index d0f03d6..b3ee18d 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -23,6 +23,8 @@ from us_equity_strategies import get_strategy_catalog DEFAULT_ACCOUNT_REGION = "US" +DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0 +DEFAULT_RESERVED_CASH_RATIO = 0.0 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 @@ -42,6 +44,8 @@ class PlatformRuntimeSettings: run_strategy_on_http: bool live_order_ack: bool max_order_notional_usd: float | None + reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD + reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO persist_strategy_runs: bool = False safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD debug_position_snapshot: bool = False @@ -113,6 +117,14 @@ def load_platform_runtime_settings( os.environ, "FIRSTRADE_MAX_ORDER_NOTIONAL_USD", ), + reserved_cash_floor_usd=_resolve_non_negative_float_env( + "FIRSTRADE_MIN_RESERVED_CASH_USD", + default=DEFAULT_RESERVED_CASH_FLOOR_USD, + ), + reserved_cash_ratio=_resolve_ratio_env( + "FIRSTRADE_RESERVED_CASH_RATIO", + default=DEFAULT_RESERVED_CASH_RATIO, + ), safe_haven_cash_substitute_threshold_usd=( max(0.0, safe_haven_cash_substitute_threshold_usd) if safe_haven_cash_substitute_threshold_usd is not None @@ -172,6 +184,22 @@ def _qqqi_income_ratio_env() -> float | None: return value +def _resolve_non_negative_float_env(name: str, *, default: float) -> float: + value = resolve_optional_float_env(os.environ, name) + if value is None: + return float(default) + if value < 0: + raise ValueError(f"{name} must be non-negative, got {value}") + return float(value) + + +def _resolve_ratio_env(name: str, *, default: float) -> float: + value = _resolve_non_negative_float_env(name, default=default) + if value > 1.0: + raise ValueError(f"{name} must be in [0,1], got {value}") + return value + + def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None: raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS") env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS" diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py new file mode 100644 index 0000000..6e61521 --- /dev/null +++ b/tests/test_decision_mapper.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from quant_platform_kit.common.models import PortfolioSnapshot, Position +from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision + +from decision_mapper import map_strategy_decision_to_plan + + +def test_applies_platform_reserved_cash_policy_to_weight_decision(): + decision = StrategyDecision( + positions=( + PositionTarget(symbol="AAPL", target_weight=0.5), + PositionTarget(symbol="MSFT", target_weight=0.5), + ), + diagnostics={"signal_description": "risk on"}, + ) + snapshot = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=20000.0, + buying_power=4000.0, + positions=(Position(symbol="AAPL", quantity=1, market_value=1000.0),), + ) + + plan = map_strategy_decision_to_plan( + decision, + snapshot=snapshot, + strategy_profile="mega_cap_leader_rotation_top50_balanced", + runtime_metadata={ + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 1500.0, + "reserved_cash_ratio": 0.03, + } + }, + ) + + assert plan["execution"]["reserved_cash"] == 1500.0 + assert plan["execution"]["investable_cash"] == 2500.0 + + +def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(): + decision = StrategyDecision( + positions=(PositionTarget(symbol="AAA", target_value=5000.0),), + diagnostics={ + "execution_annotations": { + "trade_threshold_value": 100.0, + "reserved_cash": 1200.0, + } + }, + ) + snapshot = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=10000.0, + buying_power=3000.0, + positions=(), + ) + + plan = map_strategy_decision_to_plan( + decision, + snapshot=snapshot, + strategy_profile="tqqq_growth_income", + runtime_metadata={ + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 150.0, + "reserved_cash_ratio": 0.03, + } + }, + ) + + assert plan["execution"]["reserved_cash"] == 1200.0 + assert plan["execution"]["investable_cash"] == 1800.0 diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index afc657a..29608bf 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -2,7 +2,19 @@ import pytest -from runtime_config_support import _runtime_execution_window_trading_days_env +from runtime_config_support import ( + _resolve_ratio_env, + _runtime_execution_window_trading_days_env, + load_platform_runtime_settings, +) + + +def _target_json(profile="mega_cap_leader_rotation_top50_balanced") -> str: + return ( + '{"platform_id":"firstrade","strategy_profile":"' + + profile + + '","dry_run_only":true,"execution_mode":"paper"}' + ) def test_runtime_execution_window_uses_generic_env(monkeypatch): @@ -33,6 +45,33 @@ def test_runtime_execution_window_keeps_legacy_tech_env(monkeypatch): ) +def test_reserved_cash_policy_defaults_to_zero(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json()) + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.reserved_cash_floor_usd == 0.0 + assert settings.reserved_cash_ratio == 0.0 + + +def test_reserved_cash_policy_loads_from_env(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json()) + monkeypatch.setenv("FIRSTRADE_MIN_RESERVED_CASH_USD", "250") + monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", "0.025") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.reserved_cash_floor_usd == 250.0 + assert settings.reserved_cash_ratio == 0.025 + + +def test_reserved_cash_ratio_rejects_invalid_env(monkeypatch): + monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", "1.25") + + with pytest.raises(ValueError, match="FIRSTRADE_RESERVED_CASH_RATIO"): + _resolve_ratio_env("FIRSTRADE_RESERVED_CASH_RATIO", default=0.0) + + @pytest.mark.parametrize("raw_value", ["0", "-1", "abc"]) def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_value): monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raw_value) From 19b807420f91bc95e123046eba6d7f2a93721572 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:44:51 +0800 Subject: [PATCH 2/3] Enforce platform cash reserve policy --- application/rebalance_service.py | 11 ++++------- tests/test_rebalance_service.py | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 525f50c..e66d142 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -168,13 +168,10 @@ def _runtime_metadata_with_execution_policy( settings: PlatformRuntimeSettings, ) -> dict[str, Any]: runtime_metadata = dict(metadata or {}) - runtime_metadata.setdefault( - "firstrade_execution_policy", - { - "reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0), - "reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0), - }, - ) + runtime_metadata["firstrade_execution_policy"] = { + "reserved_cash_floor_usd": float(settings.reserved_cash_floor_usd or 0.0), + "reserved_cash_ratio": float(settings.reserved_cash_ratio or 0.0), + } return runtime_metadata diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index ceca04a..9b98785 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from application.firstrade_client import FirstradeCredentials -from application.rebalance_service import run_strategy_cycle +from application.rebalance_service import _runtime_metadata_with_execution_policy, run_strategy_cycle from notifications.telegram import I18N, build_translator, render_cycle_summary from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision from runtime_config_support import PlatformRuntimeSettings @@ -36,6 +36,32 @@ def _runtime_settings_with_persistence(**overrides) -> PlatformRuntimeSettings: return PlatformRuntimeSettings(**values) +def test_runtime_metadata_uses_platform_execution_policy_over_strategy_metadata(): + metadata = { + "signal": "ok", + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 1.0, + "reserved_cash_ratio": 0.0, + }, + } + + result = _runtime_metadata_with_execution_policy( + metadata, + settings=_runtime_settings_with_persistence( + reserved_cash_floor_usd=250.0, + reserved_cash_ratio=0.03, + ), + ) + + assert result == { + "signal": "ok", + "firstrade_execution_policy": { + "reserved_cash_floor_usd": 250.0, + "reserved_cash_ratio": 0.03, + }, + } + + class FakeFirstradeClient: def __init__(self, _credentials, *, live_trading_enabled=False): self.live_trading_enabled = live_trading_enabled From f17b77617dc59d2e26c09af69c9a6a474e49a705 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:52:41 +0800 Subject: [PATCH 3/3] Reject non-finite cash reserve settings --- runtime_config_support.py | 3 +++ tests/test_runtime_config_support.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/runtime_config_support.py b/runtime_config_support.py index b3ee18d..61a604e 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import os from dataclasses import dataclass from pathlib import Path @@ -188,6 +189,8 @@ def _resolve_non_negative_float_env(name: str, *, default: float) -> float: value = resolve_optional_float_env(os.environ, name) if value is None: return float(default) + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value}") if value < 0: raise ValueError(f"{name} must be non-negative, got {value}") return float(value) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 29608bf..2af0190 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -3,6 +3,7 @@ import pytest from runtime_config_support import ( + _resolve_non_negative_float_env, _resolve_ratio_env, _runtime_execution_window_trading_days_env, load_platform_runtime_settings, @@ -72,6 +73,22 @@ def test_reserved_cash_ratio_rejects_invalid_env(monkeypatch): _resolve_ratio_env("FIRSTRADE_RESERVED_CASH_RATIO", default=0.0) +@pytest.mark.parametrize("raw_value", ["nan", "inf", "-inf"]) +def test_reserved_cash_floor_rejects_non_finite_env(monkeypatch, raw_value): + monkeypatch.setenv("FIRSTRADE_MIN_RESERVED_CASH_USD", raw_value) + + with pytest.raises(ValueError, match="FIRSTRADE_MIN_RESERVED_CASH_USD must be finite"): + _resolve_non_negative_float_env("FIRSTRADE_MIN_RESERVED_CASH_USD", default=0.0) + + +@pytest.mark.parametrize("raw_value", ["nan", "inf", "-inf"]) +def test_reserved_cash_ratio_rejects_non_finite_env(monkeypatch, raw_value): + monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", raw_value) + + with pytest.raises(ValueError, match="FIRSTRADE_RESERVED_CASH_RATIO must be finite"): + _resolve_ratio_env("FIRSTRADE_RESERVED_CASH_RATIO", default=0.0) + + @pytest.mark.parametrize("raw_value", ["0", "-1", "abc"]) def test_runtime_execution_window_rejects_invalid_generic_env(monkeypatch, raw_value): monkeypatch.setenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS", raw_value)