diff --git a/application/execution_service.py b/application/execution_service.py index 2f3fdc7..30cac3b 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -457,6 +457,23 @@ def _sell_order_quantity( ) +def _sell_delta_exceeds_floor( + *, + current_value, + target_value, + threshold_value, + min_trade_value, +) -> bool: + current = max(0.0, float(current_value or 0.0)) + target = max(0.0, float(target_value or 0.0)) + diff = current - target + if diff <= max(0.0, float(threshold_value or 0.0)): + return False + if target <= 0.0: + return True + return diff > max(0.0, float(min_trade_value or 0.0)) + + def safe_quote_last_price(symbol, *, market_data_port, notify_issue, quote_recorder=None): try: snapshot = market_data_port.get_quote(symbol) @@ -619,6 +636,7 @@ def execute_rebalance_cycle( post_sell_refresh_attempts=1, post_sell_refresh_interval_sec=0.0, sleeper=_noop_sleep, + min_order_notional_usd=0.0, safe_haven_cash_substitute_threshold_usd=DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, ) -> ExecutionCycleResult: logs: list[str] = [] @@ -674,7 +692,8 @@ def record_quote_snapshot(snapshot) -> None: available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) - current_min_trade = float(execution["current_min_trade"]) + min_order_notional = max(0.0, float(min_order_notional_usd or 0.0)) + current_min_trade = max(float(execution["current_min_trade"]), min_order_notional) dry_run_sale_proceeds = 0.0 cash_sweep_sold_this_cycle = False @@ -770,8 +789,12 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): return True for symbol in strategy_assets: - diff = target_values[symbol] - market_values[symbol] - if diff < -threshold_value and abs(diff) > current_min_trade: + if _sell_delta_exceeds_floor( + current_value=market_values[symbol], + target_value=target_values[symbol], + threshold_value=threshold_value, + min_trade_value=current_min_trade, + ): price = safe_quote_last_price( market_symbol(symbol), market_data_port=market_data_port, @@ -839,7 +862,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): with_prefix=with_prefix, kind="sell_skipped", detail=( - f"Symbol: {market_symbol(symbol)} Diff: ${abs(diff):.2f} " + f"Symbol: {market_symbol(symbol)} Diff: ${abs(market_values[symbol] - target_values[symbol]):.2f} " f"Held: {quantities[symbol]} Sellable: {sellable_quantities[symbol]} " f"(no sellable)" ), @@ -987,7 +1010,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) - current_min_trade = float(execution["current_min_trade"]) + current_min_trade = max(float(execution["current_min_trade"]), min_order_notional) if ( available_cash <= 0.0 diff --git a/application/rebalance_service.py b/application/rebalance_service.py index aa9eb30..9a0666f 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -334,6 +334,7 @@ def fetch_replanned_state(): post_sell_refresh_attempts=config.post_sell_refresh_attempts, post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec, sleeper=config.sleeper or _noop_sleep, + min_order_notional_usd=config.min_order_notional_usd, safe_haven_cash_substitute_threshold_usd=config.safe_haven_cash_substitute_threshold_usd, ) if _should_record_execution_marker(result=execution_result, config=config): diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 957a3b8..1d3295c 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -44,6 +44,7 @@ class LongBridgeRuntimeComposer: order_poll_interval_sec: int order_poll_max_attempts: int safe_haven_cash_substitute_threshold_usd: float + min_order_notional_usd: float market: str = "US" symbol_suffix: str = ".US" trading_currency: str = "USD" @@ -209,6 +210,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb symbol_suffix=self.symbol_suffix or ".US", post_sell_refresh_attempts=self.order_poll_max_attempts, post_sell_refresh_interval_sec=self.order_poll_interval_sec, + min_order_notional_usd=self.min_order_notional_usd, safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd, sleeper=self.sleeper, extra_notification_lines=(market_scope_line,), @@ -262,6 +264,7 @@ def build_runtime_composer( order_poll_interval_sec: int, order_poll_max_attempts: int, safe_haven_cash_substitute_threshold_usd: float, + min_order_notional_usd: float, dry_run_only: bool, broker_adapters: Any, strategy_adapters: Any, @@ -306,6 +309,7 @@ def build_runtime_composer( limit_buy_premium=float(limit_buy_premium), order_poll_interval_sec=int(order_poll_interval_sec), order_poll_max_attempts=int(order_poll_max_attempts), + min_order_notional_usd=float(min_order_notional_usd), safe_haven_cash_substitute_threshold_usd=float(safe_haven_cash_substitute_threshold_usd), market=str(market or "US").upper(), symbol_suffix=str(symbol_suffix or ""), diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index f410b6d..e92a3f0 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -22,6 +22,7 @@ class LongBridgeRebalanceConfig: symbol_suffix: str = ".US" post_sell_refresh_attempts: int = 1 post_sell_refresh_interval_sec: float = 0.0 + min_order_notional_usd: float = 100.0 safe_haven_cash_substitute_threshold_usd: float = 1000.0 sleeper: Callable[[float], None] | None = None extra_notification_lines: tuple[str, ...] = () diff --git a/main.py b/main.py index b139051..b718725 100644 --- a/main.py +++ b/main.py @@ -104,6 +104,7 @@ def get_project_id(): # Token refresh: days before expiry to trigger refresh TOKEN_REFRESH_THRESHOLD_DAYS = 30 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 +DEFAULT_MIN_ORDER_NOTIONAL_USD = 100.0 SEPARATOR = "━━━━━━━━━━━━━━━━━━" @@ -251,6 +252,10 @@ def _safe_haven_cash_substitute_threshold_usd() -> float: ) +def _min_order_notional_usd() -> float: + return float(getattr(RUNTIME_SETTINGS, "min_order_notional_usd", DEFAULT_MIN_ORDER_NOTIONAL_USD)) + + def build_composer(*, dry_run_only_override: bool | None = None): return build_runtime_composer( project_id=PROJECT_ID, @@ -274,6 +279,7 @@ def build_composer(*, dry_run_only_override: bool | None = None): order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, safe_haven_cash_substitute_threshold_usd=_safe_haven_cash_substitute_threshold_usd(), + min_order_notional_usd=_min_order_notional_usd(), market=MARKET, symbol_suffix=SYMBOL_SUFFIX, trading_currency=TRADING_CURRENCY, diff --git a/requirements.txt b/requirements.txt index ebeb662..e8a47b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@023641c88506c732624a7329e48b51b9dbbe3c2a -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@7d35772d1125b534d0bcca557cb6dbaf28914719 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@2e0075004239e7ede7ba256763a3441d4ec4ca73 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c6e221f71d7be4d8b1c8c94ce05452c3116e0c10 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@2b02dc947d99a593d2bbae4833f0eeeffc2eecd2 +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@5f739744b97d5a9f8981e7ae649c71a0a8ef10fa pandas requests pytz diff --git a/runtime_config_support.py b/runtime_config_support.py index 62e24f2..fc1d593 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -15,6 +15,31 @@ RuntimeTarget, resolve_runtime_target_from_env, ) +try: + from quant_platform_kit.common.broker_costs import ( + BrokerCostProfile, + minimum_economic_order_notional_usd, + ) +except ImportError: # pragma: no cover - compatibility with older pinned shared wheels + @dataclass(frozen=True) + class BrokerCostProfile: + fixed_order_fee_usd: float = 0.0 + minimum_order_fee_usd: float = 0.0 + max_fixed_fee_bps: float = 100.0 + explicit_min_order_notional_usd: float = 0.0 + + def minimum_economic_order_notional_usd(profile: BrokerCostProfile | None) -> float: + if profile is None: + return 0.0 + explicit_floor = max(0.0, float(profile.explicit_min_order_notional_usd or 0.0)) + fee_floor = max( + max(0.0, float(profile.fixed_order_fee_usd or 0.0)), + max(0.0, float(profile.minimum_order_fee_usd or 0.0)), + ) + max_fee_bps = max(0.0, float(profile.max_fixed_fee_bps or 0.0)) + if fee_floor <= 0.0 or max_fee_bps <= 0.0: + return explicit_floor + return max(explicit_floor, fee_floor / (max_fee_bps / 10_000.0)) from strategy_registry import ( LONGBRIDGE_PLATFORM, STRATEGY_CATALOG, @@ -37,6 +62,15 @@ DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0 DEFAULT_RESERVED_CASH_RATIO = 0.0 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 +DEFAULT_LONGBRIDGE_FIXED_ORDER_FEE_USD = 0.99 +DEFAULT_LONGBRIDGE_MAX_FIXED_FEE_BPS = 100.0 +DEFAULT_LONGBRIDGE_MIN_ORDER_NOTIONAL_USD = minimum_economic_order_notional_usd( + BrokerCostProfile( + fixed_order_fee_usd=DEFAULT_LONGBRIDGE_FIXED_ORDER_FEE_USD, + max_fixed_fee_bps=DEFAULT_LONGBRIDGE_MAX_FIXED_FEE_BPS, + explicit_min_order_notional_usd=100.0, + ) +) @dataclass(frozen=True) @@ -59,6 +93,7 @@ class PlatformRuntimeSettings: trading_currency: str = DEFAULT_TRADING_CURRENCY reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO + min_order_notional_usd: float = DEFAULT_LONGBRIDGE_MIN_ORDER_NOTIONAL_USD 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 @@ -236,6 +271,10 @@ def load_platform_runtime_settings( "LONGBRIDGE_RESERVED_CASH_RATIO", default=DEFAULT_RESERVED_CASH_RATIO, ), + min_order_notional_usd=_resolve_non_negative_float_env( + "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD", + default=DEFAULT_LONGBRIDGE_MIN_ORDER_NOTIONAL_USD, + ), 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 diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index a04049a..315e152 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -211,6 +211,147 @@ def test_safe_haven_target_below_cash_substitute_threshold_stays_cash(self): self.assertEqual(submitted_orders, []) self.assertEqual(result.allocation["targets"]["BOXX"], 0.0) + def test_min_order_notional_skips_small_buy(self): + submitted_orders = [] + plan = _build_plan( + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 90.0}, + market_values={"SOXL": 0.0}, + sellable_quantities={"SOXL": 0}, + quantities={"SOXL": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=90.0, + market_status="Risk on", + deploy_ratio_text="90.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="Small buy", + available_cash=90.0, + total_strategy_equity=100.0, + portfolio_rows=(("SOXL",),), + ) + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + plan, + plan["portfolio"], + plan["execution"], + plan["allocation"], + ), + market_data_port=CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-06-12", + last_price=10.0, + ) + ), + estimate_max_purchase_quantity=lambda *_args, **_kwargs: 20, + execution_port=CallableExecutionPort(lambda order_intent: submitted_orders.append(order_intent)), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=1.0, + limit_buy_premium=1.0, + min_order_notional_usd=100.0, + ) + + self.assertFalse(result.action_done) + self.assertEqual(submitted_orders, []) + + def test_min_order_notional_does_not_block_zero_target_risk_sell(self): + submitted_orders = [] + plan = _build_plan( + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 0.0}, + market_values={"SOXL": 80.0}, + sellable_quantities={"SOXL": 1}, + quantities={"SOXL": 1}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=0.0, + market_status="Risk off", + deploy_ratio_text="0.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="Risk exit", + available_cash=0.0, + total_strategy_equity=80.0, + portfolio_rows=(("SOXL",),), + ) + refreshed_plan = _build_plan( + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 0.0}, + market_values={"SOXL": 0.0}, + sellable_quantities={"SOXL": 0}, + quantities={"SOXL": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=80.0, + market_status="Risk off", + deploy_ratio_text="0.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="Risk exit", + available_cash=80.0, + total_strategy_equity=80.0, + portfolio_rows=(("SOXL",),), + ) + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + refreshed_plan, + refreshed_plan["portfolio"], + refreshed_plan["execution"], + refreshed_plan["allocation"], + ), + market_data_port=CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-06-12", + last_price=80.0, + ) + ), + estimate_max_purchase_quantity=lambda *_args, **_kwargs: 0, + execution_port=CallableExecutionPort( + lambda order_intent: ( + submitted_orders.append(order_intent), + ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.quantity, + status="accepted", + broker_order_id=f"order-{len(submitted_orders)}", + ), + )[-1] + ), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=1.0, + limit_buy_premium=1.0, + min_order_notional_usd=100.0, + ) + + self.assertTrue(result.action_done) + self.assertEqual( + [(order.symbol, order.side, order.quantity) for order in submitted_orders], + [("SOXL.US", "sell", 1)], + ) + def test_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self): submitted_orders = [] prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0} diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index 6b1758a..5a6225a 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -52,6 +52,7 @@ def fake_bootstrap_builder(**kwargs): order_poll_interval_sec=1, order_poll_max_attempts=8, safe_haven_cash_substitute_threshold_usd=1000.0, + min_order_notional_usd=100.0, dry_run_only=True, runtime_target=build_runtime_target( platform_id="longbridge", @@ -131,6 +132,7 @@ def fake_bootstrap_builder(**kwargs): assert config.strategy_display_name == "SOXL/SOXX 半导体趋势收益" assert config.dry_run_only is True assert config.safe_haven_cash_substitute_threshold_usd == 1000.0 + assert config.min_order_notional_usd == 100.0 assert config.execution_dedup_enabled is True assert config.execution_state_account_scope == "HK" assert config.execution_state_store.gcs_prefix_uri == "gs://bucket/runtime-reports" diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index bbc6b7c..929ecbb 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -142,6 +142,7 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro settings.safe_haven_cash_substitute_threshold_usd, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, ) + self.assertEqual(settings.min_order_notional_usd, 100.0) self.assertEqual(settings.reserved_cash_floor_usd, DEFAULT_RESERVED_CASH_FLOOR_USD) self.assertEqual(settings.reserved_cash_ratio, DEFAULT_RESERVED_CASH_RATIO) self.assertFalse(settings.debug_position_snapshot) @@ -272,6 +273,19 @@ def test_safe_haven_cash_substitute_threshold_is_loaded_from_env(self): self.assertEqual(settings.safe_haven_cash_substitute_threshold_usd, 750.0) + def test_min_order_notional_is_loaded_from_env(self): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), + "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD": "150", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.min_order_notional_usd, 150.0) + def test_reserved_cash_policy_is_loaded_from_env(self): with patch.dict( os.environ,