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
53 changes: 49 additions & 4 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class ExecutionCycleResult:


DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0


def _noop_sleep(_seconds):
Expand All @@ -143,6 +144,16 @@ def _safe_haven_cash_symbols(*, portfolio: dict, allocation: dict) -> tuple[str,
return tuple(dict.fromkeys(symbols))


def _positive_target_total(targets: dict) -> float:
total = 0.0
for value in dict(targets or {}).values():
try:
total += max(0.0, float(value or 0.0))
except (TypeError, ValueError):
continue
return total


def _apply_safe_haven_cash_substitution(
*,
plan,
Expand Down Expand Up @@ -266,14 +277,20 @@ def _apply_small_account_whole_share_compatibility(
target_values = dict(allocation.get("targets") or {})
candidate_symbols = tuple(
dict.fromkeys(
tuple(allocation.get("risk_symbols", ()))
str(symbol or "").strip().upper()
for symbol in tuple(allocation.get("risk_symbols", ()))
+ tuple(allocation.get("income_symbols", ()))
if str(symbol or "").strip()
)
)
if not candidate_symbols:
safe_haven_symbols = set(allocation.get("safe_haven_symbols", ()))
safe_haven_symbols = set(
_safe_haven_cash_symbols(portfolio=dict((plan or {}).get("portfolio") or {}), allocation=allocation)
)
candidate_symbols = tuple(
symbol for symbol in target_values if symbol not in safe_haven_symbols
str(symbol or "").strip().upper()
for symbol in target_values
if str(symbol or "").strip().upper() not in safe_haven_symbols
)
quote_prices = {}
for symbol in candidate_symbols:
Expand All @@ -289,11 +306,32 @@ def _apply_small_account_whole_share_compatibility(
symbols=candidate_symbols,
quantity_step=1.0,
)
safe_haven_symbols = _safe_haven_cash_symbols(
portfolio=dict((plan or {}).get("portfolio") or {}),
allocation=allocation,
)
remaining_non_safe_targets = [
symbol
for symbol in candidate_symbols
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
]
safe_haven_substituted: list[str] = []
if (
substituted
and not remaining_non_safe_targets
and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
):
Comment on lines +319 to +323

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 Count substituted targets in the small-account cap

When a risk/income target is projected to zero, this cap is checked against adjusted_targets, so the substituted sleeve no longer contributes to the $2,000 limit. For example, with a $1,900 risk target whose share price is $2,000 plus a $1,900 BOXX target, substituted is true and the adjusted total is only $1,900, causing BOXX to be zeroed and sold/blocked even though the strategy account was targeting $3,800; use the original target total or portfolio equity for the small-account limit.

Useful? React with 👍 / 👎.

for symbol in safe_haven_symbols:
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
adjusted_targets[symbol] = 0.0
Comment on lines +325 to +326

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 Track cash substitution after threshold already zeroed BOXX

When _apply_safe_haven_cash_substitution has already zeroed a BOXX target below the safe-haven threshold (for example BOXX target $900 with a $1000 threshold), this check does not record small_account_safe_haven_cash_substituted_symbols even if all risk/income targets were just projected to cash. The final cash-sweep guard then treats this as a normal zero-target sweep case and can rebuy BOXX whenever investable_cash >= threshold, undoing the intended “keep cash for unbuyable small accounts” behavior for accounts whose safe-haven sleeve was under the threshold but cash is still above it.

Useful? React with 👍 / 👎.

safe_haven_substituted.append(symbol)
adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets}
if substituted:
adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted
if safe_haven_substituted:
adjusted_allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted)
adjusted_plan = dict(plan or {})
if substituted:
if substituted or safe_haven_substituted:
adjusted_plan["allocation"] = adjusted_allocation
return adjusted_plan, adjusted_allocation

Expand Down Expand Up @@ -830,10 +868,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
**note_kwargs,
)

cash_sweep_substituted_to_cash = bool(
allocation.get("small_account_safe_haven_cash_substituted_symbols")
)
if (
not cash_sweep_sold_this_cycle
and cash_sweep_symbol
and cash_sweep_symbol in strategy_assets
and (
float(target_values.get(cash_sweep_symbol, 0.0) or 0.0) > 0.0
or not cash_sweep_substituted_to_cash
)
):
cash_sweep_price = safe_quote_last_price(
f"{cash_sweep_symbol}.US",
Expand Down
7 changes: 5 additions & 2 deletions strategy_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@
)

LONGBRIDGE_PLATFORM = "longbridge"
NASDAQ_SP500_SMART_DCA_PROFILE = "nasdaq_sp500_smart_dca"

LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles()
LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() - frozenset(
{NASDAQ_SP500_SMART_DCA_PROFILE}
)

PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = {
LONGBRIDGE_PLATFORM: frozenset({US_EQUITY_DOMAIN}),
Expand Down Expand Up @@ -54,7 +57,7 @@
profile,
platform_id=LONGBRIDGE_PLATFORM,
),
)
) - frozenset({NASDAQ_SP500_SMART_DCA_PROFILE})
LONGBRIDGE_ENABLED_PROFILES = derive_enabled_profiles_for_platform(
STRATEGY_CATALOG,
capability_matrix=PLATFORM_CAPABILITY_MATRIX,
Expand Down
8 changes: 4 additions & 4 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self):
self.assertIn("可投资现金", sent_messages[0])
self.assertIn("SOXX.US", sent_messages[0])

def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):
def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self):
plan = _build_plan(
strategy_symbols=("SOXL", "SOXX", "BOXX"),
risk_symbols=("SOXL", "SOXX"),
Expand Down Expand Up @@ -726,10 +726,10 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):
self.assertIn("🔔 【调仓指令】", 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.assertIn("市价卖出] BOXX: 6股", sent_messages[0])
self.assertNotIn("市价买入] SOXX", sent_messages[0])
self.assertIn("市价买入] BOXX: 10股", sent_messages[0])
self.assertIn("BOXX.US 目标差额 $524.92", sent_messages[0])
self.assertNotIn("市价买入] BOXX", sent_messages[0])
self.assertNotIn("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