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
208 changes: 180 additions & 28 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,22 @@ def should_sell_cash_sweep_to_fund_whole_share_buy(
return False
try:
from quant_platform_kit.common.small_account_compatibility import (
project_unbuyable_value_targets_to_cash,
apply_small_account_cash_compatibility,
format_small_account_cash_substitution_notes,
)
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
def project_unbuyable_value_targets_to_cash(
@dataclass(frozen=True)
class _SmallAccountCashCompatibilityResult:
targets: dict
whole_share_substituted_symbols: tuple[str, ...]
safe_haven_cash_substituted_symbols: tuple[str, ...]
cash_substitution_notes: tuple[dict, ...]

def _project_unbuyable_value_targets_to_cash(
target_values,
prices,
*,
symbols=None,
candidate_symbols=None,
quantity_step=1.0,
):
adjusted = {
Expand All @@ -87,23 +95,140 @@ def project_unbuyable_value_targets_to_cash(
step = max(0.0, float(quantity_step or 0.0))
if step <= 0.0:
return adjusted, ()
candidate_symbols = (
normalized_candidates = (
tuple(adjusted)
if symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols))
if candidate_symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_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:
for symbol in normalized_candidates:
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))

def apply_small_account_cash_compatibility(
target_values,
prices,
*,
candidate_symbols=None,
safe_haven_cash_symbols=(),
quantity_step=1.0,
cash_substitute_limit_usd=2000.0,
):
adjusted_targets, substituted = _project_unbuyable_value_targets_to_cash(
target_values,
prices,
candidate_symbols=candidate_symbols,
quantity_step=quantity_step,
)
normalized_candidates = (
tuple(adjusted_targets)
if candidate_symbols is None
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols))
)
remaining_non_safe_targets = [
symbol
for symbol in normalized_candidates
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
]
safe_haven_symbols = tuple(
dict.fromkeys(
str(symbol or "").strip().upper()
for symbol in safe_haven_cash_symbols
if str(symbol or "").strip()
)
)
safe_haven_substituted = []
if (
substituted
and not remaining_non_safe_targets
and _positive_target_total(adjusted_targets) <= max(0.0, float(cash_substitute_limit_usd or 0.0))
):
for symbol in safe_haven_symbols:
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
adjusted_targets[symbol] = 0.0
safe_haven_substituted.append(symbol)
normalized_targets = {
str(symbol or "").strip().upper(): float(value or 0.0)
for symbol, value in dict(target_values or {}).items()
}
normalized_prices = {
str(symbol or "").strip().upper(): float(price or 0.0)
for symbol, price in dict(prices or {}).items()
}
notes = []
if safe_haven_substituted:
for symbol in substituted:
target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0))
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
if target_value <= 0.0 or price <= 0.0:
continue
notes.append(
{
"symbol": symbol,
"target_value": target_value,
"price": price,
"cash_symbols": tuple(safe_haven_substituted),
}
)
return _SmallAccountCashCompatibilityResult(
targets=adjusted_targets,
whole_share_substituted_symbols=substituted,
safe_haven_cash_substituted_symbols=tuple(safe_haven_substituted),
cash_substitution_notes=tuple(notes),
)

def format_small_account_cash_substitution_notes(
notes,
*,
translator,
wrapper_key="buy_deferred",
detail_key="buy_deferred_small_account_cash_substitution",
cash_label_key="cash_label",
symbol_suffix=".US",
):
messages = []
seen_keys = set()
for note in tuple(notes or ()):
if not isinstance(note, Mapping):
continue
symbol = str(note.get("symbol") or "").strip().upper()
if not symbol:
continue
target_value = max(0.0, float(note.get("target_value") or 0.0))
price = max(0.0, float(note.get("price") or 0.0))
if target_value <= 0.0 or price <= 0.0:
continue
cash_symbols = tuple(
dict.fromkeys(
str(cash_symbol or "").strip().upper()
for cash_symbol in tuple(note.get("cash_symbols") or ())
if str(cash_symbol or "").strip()
)
)
cash_symbols_text = ", ".join(f"{cash_symbol}{symbol_suffix}" for cash_symbol in cash_symbols)
if not cash_symbols_text:
cash_symbols_text = translator(cash_label_key)
note_key = (symbol, f"{target_value:.2f}", cash_symbols_text)
if note_key in seen_keys:
continue
seen_keys.add(note_key)
detail = translator(
detail_key,
symbol=f"{symbol}{symbol_suffix}",
diff=f"{target_value:.2f}",
price=f"{price:.2f}",
cash_symbols=cash_symbols_text,
)
messages.append(translator(wrapper_key, detail=detail))
return tuple(messages)
from quant_platform_kit.common.quantity import (
floor_to_quantity_step,
format_quantity,
Expand Down Expand Up @@ -224,6 +349,25 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs):
print(with_prefix(message), flush=True)


def record_small_account_cash_substitution_notes(
note_logs,
*,
allocation,
translator,
with_prefix,
seen_keys,
):
for message in format_small_account_cash_substitution_notes(
allocation.get("small_account_whole_share_cash_notes") or (),
translator=translator,
):
if message in seen_keys:
continue
seen_keys.add(message)
note_logs.append(message)
print(with_prefix(message), flush=True)


def _floor_whole_share_quantity(quantity):
return normalize_order_quantity(floor_to_quantity_step(quantity, 1.0))

Expand Down Expand Up @@ -300,36 +444,29 @@ def _apply_small_account_whole_share_compatibility(
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,
)
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
):
for symbol in safe_haven_symbols:
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
adjusted_targets[symbol] = 0.0
safe_haven_substituted.append(symbol)
compatibility = apply_small_account_cash_compatibility(
target_values,
quote_prices,
candidate_symbols=candidate_symbols,
safe_haven_cash_symbols=safe_haven_symbols,
quantity_step=1.0,
cash_substitute_limit_usd=SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD,
)
adjusted_targets = compatibility.targets
substituted = compatibility.whole_share_substituted_symbols
safe_haven_substituted = compatibility.safe_haven_cash_substituted_symbols
adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets}
adjusted_allocation.pop("small_account_whole_share_cash_notes", None)
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)
if compatibility.cash_substitution_notes:
adjusted_allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes)
adjusted_plan = dict(plan or {})
if substituted or safe_haven_substituted:
adjusted_plan["allocation"] = adjusted_allocation
Expand Down Expand Up @@ -414,6 +551,7 @@ def execute_rebalance_cycle(
logs: list[str] = []
skip_logs: list[str] = []
note_logs: list[str] = []
small_account_cash_note_keys: set[str] = set()
action_done = False
sell_submitted = False
threshold_value = float(execution["trade_threshold_value"])
Expand All @@ -439,6 +577,13 @@ def execute_rebalance_cycle(
market_data_port=market_data_port,
notify_issue=notify_issue,
)
record_small_account_cash_substitution_notes(
note_logs,
allocation=allocation,
translator=translator,
with_prefix=with_prefix,
seen_keys=small_account_cash_note_keys,
)
target_values = dict(allocation["targets"])
available_cash = float(portfolio["liquid_cash"])
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
Expand Down Expand Up @@ -720,6 +865,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
market_data_port=market_data_port,
notify_issue=notify_issue,
)
record_small_account_cash_substitution_notes(
note_logs,
allocation=allocation,
translator=translator,
with_prefix=with_prefix,
seen_keys=small_account_cash_note_keys,
)
threshold_value = float(execution["trade_threshold_value"])
limit_order_symbols = set(
allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ())
Expand Down
2 changes: 2 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单",
"buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入",
"buy_deferred_small_target_gap": "{symbol} 目标差额 ${diff} 未超过 1 股价格 ${price};为避免超过目标仓位,本轮不买入",
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
"buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})",
"buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
"cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}",
Expand Down Expand Up @@ -177,6 +178,7 @@
"buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle",
"buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying",
"buy_deferred_small_target_gap": "{symbol} target gap ${diff} does not exceed the 1-share price ${price}; skipped to avoid exceeding the target allocation",
"buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this small account keeps cash this cycle and does not rebuy {cash_symbols}",
"buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}",
"buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds",
"cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}",
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ceb84a366ed1bf9a53292ff2c73e06b4baac05e2
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f2ebae8aacd8c70292c5b6115a80c6657e64ad1f
pandas
requests
pytz
Expand Down
4 changes: 3 additions & 1 deletion tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,9 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self):

self.assertEqual(len(sent_messages), 1)
self.assertIn("🔔 【调仓指令】", sent_messages[0])
self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0])
self.assertIn("SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60", sent_messages[0])
self.assertIn("小账户本轮保留现金", sent_messages[0])
self.assertIn("不回补 BOXX.US", sent_messages[0])
self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0])
self.assertIn("市价卖出] BOXX: 6股", sent_messages[0])
self.assertNotIn("市价买入] SOXX", sent_messages[0])
Expand Down