From ffc984fd02b8474d09a5143703d5812a36a85339 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:15:55 +0800 Subject: [PATCH] feat(runtime): add CASH_ONLY_EXECUTION setting (default true) Co-authored-by: Cursor --- .github/workflows/sync-cloud-run-env.yml | 2 ++ application/execution_service.py | 5 ++-- application/rebalance_service.py | 4 +++ application/runtime_broker_adapters.py | 33 +++++++++++++++++------- decision_mapper.py | 11 +++++--- notifications/telegram.py | 6 ++++- requirements.txt | 2 +- runtime_config_support.py | 7 +++++ tests/test_runtime_config_support.py | 11 ++++++++ 9 files changed, 64 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index ba6f255..1b5ee45 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -120,6 +120,7 @@ jobs: INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} + CASH_ONLY_EXECUTION: ${{ vars.CASH_ONLY_EXECUTION }} DCA_MODE: ${{ vars.DCA_MODE }} DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }} IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }} @@ -597,6 +598,7 @@ jobs: add_optional_env INCOME_LAYER_ENABLED add_optional_env INCOME_LAYER_START_USD add_optional_env INCOME_LAYER_MAX_RATIO + add_optional_env CASH_ONLY_EXECUTION add_optional_env DCA_MODE add_optional_env DCA_BASE_INVESTMENT_USD add_optional_env IBIT_ZSCORE_EXIT_ENABLED diff --git a/application/execution_service.py b/application/execution_service.py index 096d540..f23b95d 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -454,6 +454,7 @@ def execute_value_target_plan( limit_buy_premium_by_symbol: dict[str, float] | None = None, max_order_notional_usd: float | None = None, safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + cash_only_execution: bool = True, ) -> ExecutionCycleResult: del dry_run_only # ExecutionPort owns preview vs live submission. plan = substitute_small_safe_haven_targets_with_cash( @@ -571,7 +572,7 @@ def execute_value_target_plan( buy_deltas = [item for item in tradable_deltas if item[1] > 0] buys_blocked_reason: str | None = None - if buy_deltas and pending_sell_release_symbols: + if cash_only_execution and buy_deltas and pending_sell_release_symbols: estimated_buy_cost = 0.0 for symbol, delta_value, price in buy_deltas: buy_budget = min(float(delta_value), investable_cash) @@ -594,7 +595,7 @@ def execute_value_target_plan( } ) buy_deltas = [] - elif buy_deltas and raw_liquid_cash < 0.0: + elif cash_only_execution and buy_deltas and raw_liquid_cash < 0.0: buys_blocked_reason = "negative_cash" for symbol, _delta_value, _price in buy_deltas: skipped.append( diff --git a/application/rebalance_service.py b/application/rebalance_service.py index aa200a5..a242811 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -347,6 +347,7 @@ def _runtime_metadata_with_execution_policy( 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), + "cash_only_execution": bool(settings.cash_only_execution), } return runtime_metadata @@ -396,6 +397,7 @@ def log_message(message: str) -> None: live_orders=not settings.dry_run_only, live_order_ack=settings.live_order_ack, max_order_notional_usd=settings.max_order_notional_usd, + cash_only_execution=settings.cash_only_execution, ) market_data_port = broker_adapters.build_market_data_port() portfolio_port = broker_adapters.build_portfolio_port() @@ -434,6 +436,7 @@ def log_message(message: str) -> None: plan, threshold_usd=settings.safe_haven_cash_substitute_threshold_usd, ) + plan.setdefault("execution", {})["cash_only_execution"] = bool(settings.cash_only_execution) run_period = resolve_strategy_run_period( now=now, plan=plan, @@ -562,6 +565,7 @@ def log_message(message: str) -> None: limit_buy_premium_by_symbol=LIMIT_BUY_PREMIUM_BY_SYMBOL, max_order_notional_usd=settings.max_order_notional_usd, safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd, + cash_only_execution=settings.cash_only_execution, ) submitted_orders = list(execution_result.submitted_orders) skipped_orders = list(execution_result.skipped_orders) diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 23877ee..cc9efa0 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -90,14 +90,19 @@ def _resolve_total_equity( buying_power: float | None, position_market_value: float, prefer_cash_plus_positions: bool = False, + cash_only_execution: bool = True, ) -> tuple[float, str]: - del buying_power # Cash-only: never use margin buying power for strategy equity. - if cash_balance is not None: - combined_value = float(cash_balance) + max(0.0, float(position_market_value)) - if prefer_cash_plus_positions: - return combined_value, "cash_plus_positions" - if combined_value > 0.0: - return combined_value, "cash_plus_positions" + if cash_only_execution: + if cash_balance is not None: + combined_value = float(cash_balance) + max(0.0, float(position_market_value)) + if prefer_cash_plus_positions: + return combined_value, "cash_plus_positions" + if combined_value > 0.0: + return combined_value, "cash_plus_positions" + elif buying_power is not None: + combined_value = float(buying_power) + max(0.0, float(position_market_value)) + if prefer_cash_plus_positions or combined_value > 0.0: + return combined_value, "buying_power_plus_positions" balance_total = _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS) if balance_total is not None: @@ -120,6 +125,7 @@ class FirstradeBrokerAdapters: live_orders: bool = False live_order_ack: bool = False max_order_notional_usd: float | None = None + cash_only_execution: bool = True def normalize_symbol(self, symbol: str) -> str: value = str(symbol or "").strip().upper() @@ -247,7 +253,10 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: ) ) cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS) - buying_power = cash_balance + reported_buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS) + buying_power = cash_balance if self.cash_only_execution else ( + reported_buying_power if reported_buying_power is not None else cash_balance + ) position_market_value = sum(position.market_value for position in positions) total_equity, total_equity_source = _resolve_total_equity( balances=balances, @@ -255,11 +264,12 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: buying_power=buying_power, position_market_value=position_market_value, prefer_cash_plus_positions=bool(managed), + cash_only_execution=self.cash_only_execution, ) return PortfolioSnapshot( as_of=self.clock(), total_equity=float(total_equity), - buying_power=float(cash_balance or 0.0), + buying_power=float(buying_power or 0.0), cash_balance=cash_balance, positions=tuple(positions), metadata={ @@ -267,6 +277,9 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: "account_hash": self.account_hash or mask_account_id(self.account), "api_kind": "unofficial-reverse-engineered", "total_equity_source": total_equity_source, + "cash_only_execution": self.cash_only_execution, + "market_currency_cash": cash_balance, + "available_funds": reported_buying_power, }, ) @@ -321,6 +334,7 @@ def build_runtime_broker_adapters( live_orders: bool = False, live_order_ack: bool = False, max_order_notional_usd: float | None = None, + cash_only_execution: bool = True, ) -> FirstradeBrokerAdapters: return FirstradeBrokerAdapters( client=client, @@ -331,4 +345,5 @@ def build_runtime_broker_adapters( live_orders=live_orders, live_order_ack=live_order_ack, max_order_notional_usd=max_order_notional_usd, + cash_only_execution=bool(cash_only_execution), ) diff --git a/decision_mapper.py b/decision_mapper.py index a320f3e..67b84ba 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -4,9 +4,7 @@ from dataclasses import replace from typing import Any -from us_equity_strategies.cash_only_equity import ( - build_cash_only_portfolio_inputs_from_snapshot, -) +from us_equity_strategies.cash_only_equity import build_portfolio_inputs_from_snapshot from quant_platform_kit.strategy_contracts import ( PositionTarget, StrategyContractValidationError, @@ -405,8 +403,13 @@ def map_strategy_decision_to_plan( runtime_metadata: Mapping[str, Any] | None = None, ) -> dict[str, Any]: canonical_profile = resolve_canonical_profile(strategy_profile) - portfolio_inputs = build_cash_only_portfolio_inputs_from_snapshot( + raw_policy = (runtime_metadata or {}).get("firstrade_execution_policy") + cash_only_execution = True + if isinstance(raw_policy, Mapping): + cash_only_execution = bool(raw_policy.get("cash_only_execution", True)) + portfolio_inputs = build_portfolio_inputs_from_snapshot( snapshot, + cash_only_execution=cash_only_execution, include_sellable_quantities=True, ) normalized_decision, translated_annotations = _normalize_to_value_decision( diff --git a/notifications/telegram.py b/notifications/telegram.py index f9b22d6..11d5064 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -186,6 +186,7 @@ def format_small_account_whole_share_bootstrap_notes( "equity": "净值", "total_assets": "总资产(策略标的+现金)", "buying_power": "可用现金", + "buying_power_margin": "购买力", "reserved_cash": "预留现金", "investable_cash": "可投资现金", "cash_label": "现金", @@ -352,6 +353,7 @@ def format_small_account_whole_share_bootstrap_notes( "equity": "Equity", "total_assets": "Total assets", "buying_power": "Available cash", + "buying_power_margin": "Buying power", "reserved_cash": "Reserved cash", "investable_cash": "Investable cash", "cash_label": "Cash", @@ -717,7 +719,9 @@ def _format_generated_dashboard_lines( if buying_power is None: buying_power = _safe_float(portfolio.get("buying_power")) if buying_power is not None: - lines.append(f" - {translator('buying_power')}: {_format_money(buying_power)}") + cash_only_execution = bool(execution.get("cash_only_execution", portfolio.get("cash_only_execution", True))) + buying_power_label = "buying_power" if cash_only_execution else "buying_power_margin" + lines.append(f" - {translator(buying_power_label)}: {_format_money(buying_power)}") reserved_cash = _safe_float(execution.get("reserved_cash")) if reserved_cash is not None: lines.append(f" - {translator('reserved_cash')}: {_format_money(reserved_cash)}") diff --git a/requirements.txt b/requirements.txt index d8399f3..38a0f49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn firstrade==0.0.39 quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6772cb1 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@07232b0 google-cloud-storage google-auth requests diff --git a/runtime_config_support.py b/runtime_config_support.py index 2a87682..762290f 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -47,6 +47,7 @@ class PlatformRuntimeSettings: live_order_ack: bool max_order_notional_usd: float | None runtime_target_enabled: bool = True + cash_only_execution: bool = True reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO persist_strategy_runs: bool = False @@ -164,6 +165,7 @@ def load_platform_runtime_settings( tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), dry_run_only=dry_run_only, runtime_target_enabled=_runtime_target_enabled_env(), + cash_only_execution=_resolve_cash_only_execution_env(), live_trading_enabled=resolve_bool_value(os.getenv("FIRSTRADE_ENABLE_LIVE_TRADING")), run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")), live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")), @@ -368,6 +370,11 @@ def _runtime_target_enabled_env() -> bool: return True if value is None else value +def _resolve_cash_only_execution_env(name: str = "CASH_ONLY_EXECUTION") -> bool: + value = _optional_bool_env(name) + return True if value is None else value + + def _optional_ratio_env(name: str) -> float | None: value = resolve_optional_float_env(os.environ, name) if value is None: diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index febef43..e8a2012 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -101,6 +101,17 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch): assert settings.reserved_cash_ratio == 0.025 +def test_cash_only_execution_defaults_true_and_loads_override(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income")) + + default_settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + assert default_settings.cash_only_execution is True + + monkeypatch.setenv("CASH_ONLY_EXECUTION", "false") + disabled_settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + assert disabled_settings.cash_only_execution is False + + def test_income_layer_overrides_load_from_env(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income")) monkeypatch.setenv("INCOME_LAYER_ENABLED", "false")