diff --git a/.env.example b/.env.example index 05d1bda..bc43c23 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,5 @@ FIRSTRADE_RUN_SMOKE_ON_HTTP=false FIRSTRADE_RUN_STRATEGY_ON_HTTP=false FIRSTRADE_LIVE_ORDER_ACK=false FIRSTRADE_MAX_ORDER_NOTIONAL_USD=25 +FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=1000 FIRSTRADE_SMOKE_SYMBOL=SPY diff --git a/README.md b/README.md index 84a13a2..4a690f7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ 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 | Single-order cap for strategy-generated orders, default `25` | +| `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 @@ -243,6 +244,7 @@ HTTP 策略闭环实盘还必须额外满足: - `FIRSTRADE_DRY_RUN_ONLY=false` - `FIRSTRADE_LIVE_ORDER_ACK=true` - 单笔金额不超过 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` +- `BOXX`/`BIL` 等避险现金替代标的目标金额低于 `FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` 时保留现金,默认门槛 `1000` USD 策略闭环生成的是整数股限价单。如果 `FIRSTRADE_MAX_ORDER_NOTIONAL_USD` 低于目标标的当前价格,本轮会跳过该订单,而不是放大金额。 diff --git a/application/execution_service.py b/application/execution_service.py index 55b414a..f62aeb1 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -16,10 +16,54 @@ class ExecutionCycleResult: action_done: bool +DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 + + def _floor_quantity(quantity: float) -> int: return max(0, int(float(quantity or 0.0))) +def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str, Any]) -> tuple[str, ...]: + symbols: list[str] = [] + for symbol in allocation.get("safe_haven_symbols", ()): + normalized = str(symbol or "").strip().upper() + if normalized: + symbols.append(normalized) + cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper() + if cash_sweep_symbol: + symbols.append(cash_sweep_symbol) + return tuple(dict.fromkeys(symbols)) + + +def substitute_small_safe_haven_targets_with_cash( + plan: dict[str, Any], + *, + threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, +) -> dict[str, Any]: + """Return a plan whose small safe-haven target values are left as cash.""" + threshold = max(0.0, float(threshold_usd or 0.0)) + if threshold <= 0.0: + return dict(plan or {}) + + adjusted_plan = dict(plan or {}) + allocation = dict(adjusted_plan.get("allocation") or {}) + portfolio = dict(adjusted_plan.get("portfolio") or {}) + targets = { + str(symbol).strip().upper(): float(value or 0.0) + for symbol, value in dict(allocation.get("targets") or {}).items() + } + changed = False + for symbol in _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation): + target_value = float(targets.get(symbol, 0.0) or 0.0) + if 0.0 < target_value < threshold: + targets[symbol] = 0.0 + changed = True + if changed: + allocation["targets"] = targets + adjusted_plan["allocation"] = allocation + return adjusted_plan + + def _quote_price(market_data_port: MarketDataPort, symbol: str) -> float | None: try: price = float(market_data_port.get_quote(symbol).last_price) @@ -67,8 +111,13 @@ def execute_value_target_plan( limit_sell_discount: float = 0.995, limit_buy_premium: float = 1.005, max_order_notional_usd: float = 25.0, + safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, ) -> ExecutionCycleResult: del dry_run_only # ExecutionPort owns preview vs live submission. + plan = substitute_small_safe_haven_targets_with_cash( + plan, + threshold_usd=safe_haven_cash_substitute_threshold_usd, + ) allocation = dict(plan.get("allocation") or {}) portfolio = dict(plan.get("portfolio") or {}) execution = dict(plan.get("execution") or {}) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index d43e2b8..4feddc4 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -14,7 +14,10 @@ import pandas as pd -from application.execution_service import execute_value_target_plan +from application.execution_service import ( + execute_value_target_plan, + substitute_small_safe_haven_targets_with_cash, +) from application.firstrade_client import ( FirstradeBrokerClient, FirstradeCredentials, @@ -192,6 +195,10 @@ def run_strategy_cycle( strategy_profile=settings.strategy_profile, runtime_metadata=getattr(evaluation, "metadata", None), ) + plan = substitute_small_safe_haven_targets_with_cash( + plan, + threshold_usd=settings.safe_haven_cash_substitute_threshold_usd, + ) execution_result = execute_value_target_plan( plan=plan, market_data_port=market_data_port, @@ -200,6 +207,7 @@ def run_strategy_cycle( limit_sell_discount=LIMIT_SELL_DISCOUNT, limit_buy_premium=LIMIT_BUY_PREMIUM, max_order_notional_usd=settings.max_order_notional_usd, + safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd, ) result = { "ok": True, diff --git a/runtime_config_support.py b/runtime_config_support.py index 4f3eafb..a6ad523 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -23,6 +23,7 @@ from us_equity_strategies import get_strategy_catalog DEFAULT_ACCOUNT_REGION = "US" +DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 @dataclass(frozen=True) @@ -41,6 +42,7 @@ class PlatformRuntimeSettings: run_strategy_on_http: bool live_order_ack: bool max_order_notional_usd: float + safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD debug_position_snapshot: bool = False income_threshold_usd: float | None = None qqqi_income_ratio: float | None = None @@ -67,6 +69,10 @@ def load_platform_runtime_settings( ) -> PlatformRuntimeSettings: dry_run_only = resolve_bool_value(os.getenv("FIRSTRADE_DRY_RUN_ONLY", "true")) account_prefix = os.getenv("ACCOUNT_PREFIX", "FIRSTRADE") + safe_haven_cash_substitute_threshold_usd = resolve_optional_float_env( + os.environ, + "FIRSTRADE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", + ) runtime_target = _resolve_runtime_target(dry_run_only=dry_run_only) strategy_definition = resolve_strategy_definition( runtime_target.strategy_profile, @@ -105,6 +111,11 @@ def load_platform_runtime_settings( resolve_optional_float_env(os.environ, "FIRSTRADE_MAX_ORDER_NOTIONAL_USD") or 25.0 ), + 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 + else DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD + ), debug_position_snapshot=resolve_bool_value(os.getenv("FIRSTRADE_DEBUG_POSITION_SNAPSHOT")), income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"), qqqi_income_ratio=_qqqi_income_ratio_env(), diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index ffef643..c3875bb 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -3,7 +3,10 @@ from dataclasses import dataclass from datetime import datetime, timezone -from application.execution_service import execute_value_target_plan +from application.execution_service import ( + execute_value_target_plan, + substitute_small_safe_haven_targets_with_cash, +) from quant_platform_kit.common.models import ExecutionReport, QuoteSnapshot @@ -89,3 +92,39 @@ def test_execute_value_target_plan_skips_when_cap_cannot_buy_one_share(): assert result.skipped_orders == ( {"symbol": "SPY", "reason": "buy_quantity_zero", "max_order_notional_usd": 25.0}, ) + + +def test_execute_value_target_plan_leaves_small_safe_haven_target_as_cash(): + execution_port = FakeExecutionPort() + plan = { + "allocation": { + "targets": {"AAA": 1500.0, "BOXX": 750.0}, + "safe_haven_symbols": ("BOXX",), + }, + "portfolio": { + "market_values": {"AAA": 0.0, "BOXX": 0.0}, + "sellable_quantities": {}, + "liquid_cash": 2500.0, + "cash_sweep_symbol": "BOXX", + }, + "execution": {"current_min_trade": 1.0, "investable_cash": 2500.0}, + } + + result = execute_value_target_plan( + plan=plan, + market_data_port=FakeMarketDataPort({"AAA": 100.0, "BOXX": 100.0}), + execution_port=execution_port, + dry_run_only=True, + max_order_notional_usd=2500.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is True + assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [ + ("buy", "AAA", 15.0), + ] + adjusted_plan = substitute_small_safe_haven_targets_with_cash( + plan, + threshold_usd=1000.0, + ) + assert adjusted_plan["allocation"]["targets"]["BOXX"] == 0.0