diff --git a/application/execution_service.py b/application/execution_service.py index 065f496..096d540 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -497,6 +497,7 @@ def execute_value_target_plan( 0.0, float(execution.get("investable_cash") or portfolio.get("liquid_cash") or 0.0), ) + raw_liquid_cash = float(portfolio.get("liquid_cash") or 0.0) order_notional_cap = ( max(0.0, float(max_order_notional_usd)) if max_order_notional_usd is not None and float(max_order_notional_usd) > 0.0 @@ -506,6 +507,7 @@ def execute_value_target_plan( submitted: list[dict[str, Any]] = [] skipped: list[dict[str, Any]] = [] reference_prices: dict[str, float] = {} + pending_sell_release_symbols: list[str] = [] tradable_deltas: list[tuple[str, float, float]] = [] for symbol in sorted(set(targets) | set(market_values)): @@ -540,6 +542,7 @@ def execute_value_target_plan( ) quantity = _floor_quantity(sell_budget / price) if quantity <= 0: + pending_sell_release_symbols.append(symbol) skipped.append( { "symbol": symbol, @@ -552,19 +555,58 @@ def execute_value_target_plan( } ) continue + sell_limit_price = price * float(limit_sell_discount) submitted.append( _submit_order( execution_port, symbol=symbol, side="sell", quantity=quantity, - limit_price=price * float(limit_sell_discount), + limit_price=sell_limit_price, max_notional_usd=max_order_notional_usd, ) ) + investable_cash += quantity * sell_limit_price continue - for symbol, delta_value, price in [item for item in tradable_deltas if item[1] > 0]: + 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: + estimated_buy_cost = 0.0 + for symbol, delta_value, price in buy_deltas: + buy_budget = min(float(delta_value), investable_cash) + if order_notional_cap is not None: + buy_budget = min(buy_budget, order_notional_cap) + limit_price = _limit_buy_price( + symbol, price, limit_buy_premium, limit_buy_premium_by_symbol + ) + quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0 + if quantity > 0: + estimated_buy_cost += quantity * limit_price + if estimated_buy_cost > investable_cash: + buys_blocked_reason = "pending_sell_release" + for symbol, _delta_value, _price in buy_deltas: + skipped.append( + { + "symbol": symbol, + "reason": "pending_sell_release", + "pending_sell_symbols": list(pending_sell_release_symbols), + } + ) + buy_deltas = [] + elif buy_deltas and raw_liquid_cash < 0.0: + buys_blocked_reason = "negative_cash" + for symbol, _delta_value, _price in buy_deltas: + skipped.append( + { + "symbol": symbol, + "reason": "negative_cash", + "liquid_cash": round(raw_liquid_cash, 2), + } + ) + buy_deltas = [] + + for symbol, delta_value, price in buy_deltas: buy_budget = min(float(delta_value), investable_cash) if order_notional_cap is not None: buy_budget = min(buy_budget, order_notional_cap) diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 715fd88..23877ee 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -91,32 +91,22 @@ def _resolve_total_equity( position_market_value: float, prefer_cash_plus_positions: bool = False, ) -> tuple[float, str]: - resolved_cash = _positive_or_none(cash_balance) - if resolved_cash is not None: - combined_value = resolved_cash + max(0.0, float(position_market_value)) - if prefer_cash_plus_positions and combined_value > 0.0: + 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" - else: - combined_value = None - - balance_total = _positive_or_none( - _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS) - ) - if balance_total is not None: - return balance_total, "balance_total" - - if combined_value is not None: if combined_value > 0.0: return combined_value, "cash_plus_positions" + balance_total = _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS) + if balance_total is not None: + return float(balance_total), "balance_total" + positive_position_value = _positive_or_none(position_market_value) if positive_position_value is not None: return positive_position_value, "positions" - positive_buying_power = _positive_or_none(buying_power) - if positive_buying_power is not None: - return positive_buying_power, "buying_power_fallback" - return 0.0, "unresolved" @@ -256,8 +246,8 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: account_id=mask_account_id(self.account), ) ) - buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS) cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS) + buying_power = cash_balance position_market_value = sum(position.market_value for position in positions) total_equity, total_equity_source = _resolve_total_equity( balances=balances, @@ -269,7 +259,7 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: return PortfolioSnapshot( as_of=self.clock(), total_equity=float(total_equity), - buying_power=buying_power, + buying_power=float(cash_balance or 0.0), cash_balance=cash_balance, positions=tuple(positions), metadata={ diff --git a/decision_mapper.py b/decision_mapper.py index ae92e3d..a320f3e 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -4,13 +4,15 @@ from dataclasses import replace from typing import Any +from us_equity_strategies.cash_only_equity import ( + build_cash_only_portfolio_inputs_from_snapshot, +) from quant_platform_kit.strategy_contracts import ( PositionTarget, StrategyContractValidationError, StrategyDecision, ValueTargetExecutionAnnotations, build_value_target_execution_annotations, - build_value_target_portfolio_inputs_from_snapshot, build_value_target_runtime_plan, resolve_decision_target_mode, translate_decision_to_target_mode, @@ -403,10 +405,9 @@ 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_value_target_portfolio_inputs_from_snapshot( + portfolio_inputs = build_cash_only_portfolio_inputs_from_snapshot( snapshot, include_sellable_quantities=True, - liquid_cash=float(snapshot.buying_power or snapshot.cash_balance or 0.0), ) normalized_decision, translated_annotations = _normalize_to_value_decision( decision, diff --git a/notifications/telegram.py b/notifications/telegram.py index 303db48..f9b22d6 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -185,7 +185,7 @@ def format_small_account_whole_share_bootstrap_notes( "account_overview_title": "📌 策略账户概览", "equity": "净值", "total_assets": "总资产(策略标的+现金)", - "buying_power": "购买力", + "buying_power": "可用现金", "reserved_cash": "预留现金", "investable_cash": "可投资现金", "cash_label": "现金", @@ -332,10 +332,14 @@ def format_small_account_whole_share_bootstrap_notes( "strategy_name_ibit_smart_dca": "IBIT 比特币 ETF 智能定投", "skip_reason_below_trade_threshold": "低于调仓阈值", "skip_reason_quote_unavailable": "无法获取报价", - "skip_reason_sell_quantity_zero": "卖出股数为0", - "skip_reason_buy_quantity_zero": "买入股数为0", + "skip_reason_sell_quantity_zero": "整数股不足 1 股,无需下单", + "skip_reason_pending_sell_release": "需先减仓但整数股卖单未成交,现金不足以对应买入,跳过以避免融资", + "skip_reason_negative_cash": "账户现金为负,跳过买入以避免额外融资", + "skip_reason_buy_quantity_zero": "整数股不足 1 股,无需下单", "skip_reason_insufficient_cash_for_whole_share": "现金不足以买入一整股", "skip_reason_unknown": "未知原因", + "deferred_orders_line": "ℹ️ [本轮跳过] {details}", + "skip_symbols_reason": "{symbols}({reason})", }, "en": { "rebalance_title": "🔔 【Rebalance Instruction】", @@ -347,7 +351,7 @@ def format_small_account_whole_share_bootstrap_notes( "account_overview_title": "📌 Strategy Account", "equity": "Equity", "total_assets": "Total assets", - "buying_power": "Buying power", + "buying_power": "Available cash", "reserved_cash": "Reserved cash", "investable_cash": "Investable cash", "cash_label": "Cash", @@ -494,10 +498,14 @@ def format_small_account_whole_share_bootstrap_notes( "strategy_name_ibit_smart_dca": "IBIT Smart DCA", "skip_reason_below_trade_threshold": "below trade threshold", "skip_reason_quote_unavailable": "quote unavailable", - "skip_reason_sell_quantity_zero": "sell quantity rounds to 0", - "skip_reason_buy_quantity_zero": "buy quantity rounds to 0", + "skip_reason_sell_quantity_zero": "whole-share quantity rounds to 0; no order needed", + "skip_reason_pending_sell_release": "trim still pending but whole-share sell rounded to 0; buy skipped this cycle to avoid margin", + "skip_reason_negative_cash": "account cash is negative; buy skipped to avoid additional margin", + "skip_reason_buy_quantity_zero": "whole-share quantity rounds to 0; no order needed", "skip_reason_insufficient_cash_for_whole_share": "insufficient cash for one whole share", "skip_reason_unknown": "unknown reason", + "deferred_orders_line": "ℹ️ [Skipped this cycle] {details}", + "skip_symbols_reason": "{symbols} ({reason})", }, } @@ -705,9 +713,9 @@ def _format_generated_dashboard_lines( total_equity = _safe_float(portfolio.get("total_equity")) if total_equity is not None: lines.append(f" - {translator('total_assets')}: {_format_money(total_equity)}") - buying_power = _safe_float(portfolio.get("buying_power")) + buying_power = _safe_float(portfolio.get("liquid_cash")) if buying_power is None: - buying_power = _safe_float(portfolio.get("liquid_cash")) + buying_power = _safe_float(portfolio.get("buying_power")) if buying_power is not None: lines.append(f" - {translator('buying_power')}: {_format_money(buying_power)}") reserved_cash = _safe_float(execution.get("reserved_cash")) @@ -1115,7 +1123,16 @@ def _format_skipped_reason(skipped: list[Mapping[str, Any]], *, translator: Call grouped[reason].append(symbol) parts = [] for reason, symbols in grouped.items(): - parts.append(f"{reason}:{','.join(symbols)}" if symbols else reason) + if symbols: + parts.append( + translator( + "skip_symbols_reason", + symbols=",".join(symbols), + reason=reason, + ) + ) + else: + parts.append(reason) return ", ".join(parts) if parts else translator("no_executable_orders") @@ -1185,6 +1202,18 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str: if submitted: lines.append(translator("order_logs_title")) lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator)) + meaningful_skipped = [ + item + for item in skipped + if str(item.get("reason") or "") != "below_trade_threshold" + ] + if meaningful_skipped: + lines.append( + translator( + "deferred_orders_line", + details=_format_skipped_reason(meaningful_skipped, translator=translator), + ) + ) elif skipped and has_rebalance_attempt: lines.append(translator("order_logs_title")) reason = _format_skipped_reason(skipped, translator=translator) diff --git a/requirements.txt b/requirements.txt index 76da6ff..d8399f3 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@0cbdacc95fb9041f590472254aef8f1cea35adf8 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6772cb1 google-cloud-storage google-auth requests diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index dda112c..5b732b8 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -797,7 +797,7 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_chinese_template assert "🎯 信号: 入场信号" in message assert "调仓变化: BOXX +7.89 USD, QQQ +44.39 USD, TQQQ +44.39 USD" in message assert "🧾 执行明细" in message - assert "未下单: 原因=买入股数为0:TQQQ,QQQ, 低于调仓阈值:BOXX" in message + assert "未下单: 原因=TQQQ,QQQ(整数股不足 1 股,无需下单), BOXX(低于调仓阈值)" in message assert "profile:" not in message assert "targets:" not in message @@ -972,7 +972,10 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template assert "🎯 Signal: Entry Signal" in message assert "Target changes: BOXX +7.89 USD, QQQ +44.39 USD, TQQQ +44.39 USD" in message assert "🧾 Execution details" in message - assert "No order submitted: reason=buy quantity rounds to 0:TQQQ,QQQ, below trade threshold:BOXX" in message + assert ( + "No order submitted: reason=TQQQ,QQQ (whole-share quantity rounds to 0; no order needed), " + "BOXX (below trade threshold)" + ) in message assert "账户" not in message assert "信号" not in message assert "profile:" not in message @@ -1009,7 +1012,7 @@ def test_render_cycle_summary_shows_funding_blocked_banner(): lang="zh", ) - assert "⚠️ 资金不足,本周期不再自动重试: 现金不足以买入一整股:NVDA" in message + assert "⚠️ 资金不足,本周期不再自动重试: NVDA(现金不足以买入一整股)" in message def test_render_cycle_summary_shows_retryable_execution_blocked_banner(): @@ -1041,4 +1044,4 @@ def test_render_cycle_summary_shows_retryable_execution_blocked_banner(): lang="en", ) - assert "⚠️ Execution blocked; retryable within window: quote unavailable:NVDA" in message + assert "⚠️ Execution blocked; retryable within window: NVDA (quote unavailable)" in message