Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,42 @@ def should_sell_cash_sweep_to_fund_whole_share_buy(
if current_buying_power + sweep_capacity >= quote_price:
return True
return False
try:
from quant_platform_kit.common.small_account_compatibility import (
project_unbuyable_value_targets_to_cash,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
def project_unbuyable_value_targets_to_cash(
target_values,
prices,
*,
symbols=None,
quantity_step=1.0,
):
adjusted = {
str(symbol or "").strip().upper(): float(value or 0.0)
for symbol, value in dict(target_values or {}).items()
}
step = max(0.0, float(quantity_step or 0.0))
if step <= 0.0:
return adjusted, ()
candidate_symbols = (
tuple(adjusted)
if symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols))
)
normalized_prices = {
str(symbol or "").strip().upper(): float(price or 0.0)
for symbol, price in dict(prices or {}).items()
}
substituted = []
for symbol in candidate_symbols:
target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0))
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
if price > 0.0 and 0.0 < target_value < (price * step):
adjusted[symbol] = 0.0
substituted.append(symbol)
return adjusted, tuple(dict.fromkeys(substituted))
from quant_platform_kit.common.quantity import (
floor_to_quantity_step,
format_quantity,
Expand Down Expand Up @@ -219,6 +255,49 @@ def safe_quote_last_price(symbol, *, market_data_port, notify_issue):
return None


def _apply_small_account_whole_share_compatibility(
*,
plan,
allocation,
strategy_assets,
market_data_port,
notify_issue,
) -> tuple[dict, dict]:
target_values = dict(allocation.get("targets") or {})
candidate_symbols = tuple(
dict.fromkeys(
tuple(allocation.get("risk_symbols", ()))
+ tuple(allocation.get("income_symbols", ()))
)
)
if not candidate_symbols:
safe_haven_symbols = set(allocation.get("safe_haven_symbols", ()))
candidate_symbols = tuple(
symbol for symbol in target_values if symbol not in safe_haven_symbols
Comment on lines +274 to +276

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize safe-haven symbols before candidate filtering

When risk_symbols/income_symbols are empty, this branch compares targets keys to safe_haven_symbols without case normalization, so mixed-case plans (for example targets using "BOXX" and safe_haven_symbols containing "boxx") will incorrectly treat safe-haven assets as substitution candidates. In that case, the new small-account projection can zero a safe-haven target below 1 share price, which skips intended cash-substitute buys and leaves cash idle for pure safe-haven allocations.

Useful? React with 👍 / 👎.

)
quote_prices = {}
for symbol in candidate_symbols:
try:
price = float(market_data_port.get_quote(f"{symbol}.US").last_price)
except Exception:
continue
if price > 0.0:
quote_prices[symbol] = price
adjusted_targets, substituted = project_unbuyable_value_targets_to_cash(
target_values,
quote_prices,
symbols=candidate_symbols,
quantity_step=1.0,
)
adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets}
if substituted:
adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted
adjusted_plan = dict(plan or {})
if substituted:
adjusted_plan["allocation"] = adjusted_allocation
return adjusted_plan, adjusted_allocation


def estimate_cash_buy_quantity_safe(
trade_context,
symbol,
Expand Down Expand Up @@ -314,8 +393,15 @@ def execute_rebalance_cycle(
allocation=allocation,
threshold_usd=safe_haven_cash_substitute_threshold_usd,
)
target_values = dict(allocation["targets"])
cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper()
plan, allocation = _apply_small_account_whole_share_compatibility(
plan=plan,
allocation=allocation,
strategy_assets=strategy_assets,
market_data_port=market_data_port,
notify_issue=notify_issue,
)
target_values = dict(allocation["targets"])
available_cash = float(portfolio["liquid_cash"])
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
investable_cash = float(execution["investable_cash"])
Expand Down Expand Up @@ -589,6 +675,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
allocation=allocation,
threshold_usd=safe_haven_cash_substitute_threshold_usd,
)
plan, allocation = _apply_small_account_whole_share_compatibility(
plan=plan,
allocation=allocation,
strategy_assets=tuple(allocation["strategy_symbols"]),
market_data_port=market_data_port,
notify_issue=notify_issue,
)
threshold_value = float(execution["trade_threshold_value"])
limit_order_symbols = set(
allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ())
Expand Down
100 changes: 97 additions & 3 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,101 @@ 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_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self):
submitted_orders = []
prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0}
plan = _build_plan(
strategy_symbols=("SOXL", "SOXX", "BOXX"),
risk_symbols=("SOXL", "SOXX"),
safe_haven_symbols=("BOXX",),
targets={"SOXL": 541.58, "SOXX": 154.74, "BOXX": 77.37},
market_values={"SOXL": 0.0, "SOXX": 536.88, "BOXX": 0.0},
sellable_quantities={"SOXL": 0, "SOXX": 1, "BOXX": 0},
quantities={"SOXL": 0, "SOXX": 1, "BOXX": 0},
current_min_trade=7.74,
trade_threshold_value=7.74,
investable_cash=213.60,
market_status="Risk on",
deploy_ratio_text="90.0%",
income_ratio_text="0.0%",
income_locked_ratio_text="0.0%",
signal_message="SOXX above gate",
available_cash=236.81,
total_strategy_equity=773.69,
portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)),
)
refreshed_plan = _build_plan(
strategy_symbols=("SOXL", "SOXX", "BOXX"),
risk_symbols=("SOXL", "SOXX"),
safe_haven_symbols=("BOXX",),
targets={"SOXL": 541.58, "SOXX": 154.74, "BOXX": 77.37},
market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0},
quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0},
current_min_trade=7.74,
trade_threshold_value=7.74,
investable_cash=750.48,
market_status="Risk on",
deploy_ratio_text="90.0%",
income_ratio_text="0.0%",
income_locked_ratio_text="0.0%",
signal_message="SOXX above gate",
available_cash=773.69,
total_strategy_equity=773.69,
portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)),
)

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-05-22",
last_price=prices[str(symbol).replace(".US", "")],
)
),
estimate_max_purchase_quantity=lambda *_args, **_kwargs: 10,
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,
safe_haven_cash_substitute_threshold_usd=1000.0,
)

self.assertTrue(result.action_done)
self.assertEqual(result.allocation["targets"]["SOXX"], 0.0)
self.assertEqual(
result.allocation["small_account_whole_share_substituted_symbols"],
("SOXX",),
)
self.assertEqual([(order.symbol, order.side, order.quantity) for order in submitted_orders], [
("SOXX.US", "sell", 1),
("SOXL.US", "buy", 2),
])

def test_run_strategy_prefers_portfolio_port_runtime_path(self):
sent_messages = []
observed = {}
Expand Down Expand Up @@ -629,13 +724,12 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):

self.assertEqual(len(sent_messages), 1)
self.assertIn("🔔 【调仓指令】", sent_messages[0])
self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0])
self.assertIn("SOXX.US 目标差额 $163.14 未超过 1 股价格 $504.60", sent_messages[0])
self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0])
self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0])
self.assertNotIn("市价卖出] BOXX", sent_messages[0])
self.assertNotIn("市价买入] SOXX", sent_messages[0])
self.assertIn("市价买入] BOXX: 10股", sent_messages[0])
self.assertIn("买入说明", sent_messages[0])
self.assertIn("BOXX.US 目标差额 $524.92", sent_messages[0])
self.assertNotIn("限价买入] SOXX", sent_messages[0])

def test_target_gap_below_one_share_does_not_report_cash_shortage(self):
Expand Down