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
46 changes: 44 additions & 2 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)):
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
30 changes: 10 additions & 20 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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,
Expand All @@ -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={
Expand Down
7 changes: 4 additions & 3 deletions decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 38 additions & 9 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "现金",
Expand Down Expand Up @@ -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】",
Expand All @@ -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",
Expand Down Expand Up @@ -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})",
},
}

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Loading