diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 0089dab..e112242 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -13,6 +13,13 @@ def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail): print(with_prefix(message), flush=True) +def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): + detail = translator(kind, **kwargs) + message = translator("buy_deferred", detail=detail) + note_logs.append(message) + print(with_prefix(message), flush=True) + + def run_strategy( *, project_id, @@ -61,6 +68,7 @@ def run_strategy( logs = [] skip_logs = [] + note_logs = [] action_done = False sell_submitted = False threshold_value = plan["threshold_value"] @@ -131,94 +139,106 @@ def run_strategy( limit_order_symbols = set(plan["limit_order_symbols"]) investable_cash = plan["investable_cash"] - for symbol in plan["strategy_assets"]: + buy_candidates = [ + symbol + for symbol in plan["strategy_assets"] + if (plan["targets"][symbol] - plan["market_values"][symbol]) > threshold_value + and abs(plan["targets"][symbol] - plan["market_values"][symbol]) > plan["current_min_trade"] + ] + if buy_candidates and investable_cash <= 0: + buy_candidates = [] + for symbol in buy_candidates: diff = plan["targets"][symbol] - plan["market_values"][symbol] - if diff > threshold_value and abs(diff) > plan["current_min_trade"]: - price = safe_quote_last_price( - quote_context, + price = safe_quote_last_price( + quote_context, + f"{symbol}.US", + fetch_last_price=fetch_last_price, + notify_issue=notify_issue, + ) + if price is None: + continue + can_buy_value = min(diff, investable_cash) + if can_buy_value > price: + is_limit_order = symbol in limit_order_symbols + order_kind = "limit" if is_limit_order else "market" + ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2) + budget_price = ref_price if is_limit_order else price + budget_quantity = int(can_buy_value // budget_price) + cash_limit_quantity = estimate_cash_buy_quantity_safe( + trade_context, f"{symbol}.US", - fetch_last_price=fetch_last_price, + order_kind, + ref_price, + estimate_max_purchase_quantity=estimate_max_purchase_quantity, notify_issue=notify_issue, ) - if price is None: + if cash_limit_quantity is None: + continue + + quantity = min(budget_quantity, cash_limit_quantity) + cost_estimate = 0.0 + if quantity <= 0: + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_cash_limit", + symbol=f"{symbol}.US", + diff=f"{diff:.2f}", + budget_qty=budget_quantity, + ) continue - can_buy_value = min(diff, investable_cash) - if can_buy_value > price: - is_limit_order = symbol in limit_order_symbols - order_kind = "limit" if is_limit_order else "market" - ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2) - budget_price = ref_price if is_limit_order else price - budget_quantity = int(can_buy_value // budget_price) - cash_limit_quantity = estimate_cash_buy_quantity_safe( + + if is_limit_order: + submitted = submit_order_with_alert( trade_context, f"{symbol}.US", - order_kind, - ref_price, - estimate_max_purchase_quantity=estimate_max_purchase_quantity, - notify_issue=notify_issue, + "limit", + "buy", + quantity, + logs, + translator("limit_buy", symbol=symbol, qty=quantity, price=ref_price), + submitted_price=ref_price, ) - if cash_limit_quantity is None: - continue - - quantity = min(budget_quantity, cash_limit_quantity) - cost_estimate = 0.0 - if quantity <= 0: - record_skip_log( - skip_logs, - translator=translator, - with_prefix=with_prefix, - kind="buy_skipped", - detail=( - f"Symbol: {symbol}.US Diff: ${diff:.2f} Cash: ${investable_cash:.2f} " - f"Budget qty: {budget_quantity} Cash limit qty: {cash_limit_quantity}" - ), - ) - continue - - if is_limit_order: - submitted = submit_order_with_alert( - trade_context, - f"{symbol}.US", - "limit", - "buy", - quantity, - logs, - translator("limit_buy", symbol=symbol, qty=quantity, price=ref_price), - submitted_price=ref_price, - ) - cost_estimate = quantity * budget_price - else: - submitted = submit_order_with_alert( - trade_context, - f"{symbol}.US", - "market", - "buy", - quantity, - logs, - translator("market_buy", symbol=symbol, qty=quantity, price=round(price, 2)), - ) - cost_estimate = quantity * budget_price - - if submitted: - investable_cash = max(0, investable_cash - cost_estimate) - action_done = True + cost_estimate = quantity * budget_price else: - record_skip_log( - skip_logs, - translator=translator, - with_prefix=with_prefix, - kind="buy_skipped", - detail=( - f"Symbol: {symbol}.US Diff: ${diff:.2f} Cash: ${investable_cash:.2f} " - f"Price: ${price:.2f} (insufficient for 1 share)" - ), + submitted = submit_order_with_alert( + trade_context, + f"{symbol}.US", + "market", + "buy", + quantity, + logs, + translator("market_buy", symbol=symbol, qty=quantity, price=round(price, 2)), ) + cost_estimate = quantity * budget_price + + if submitted: + investable_cash = max(0, investable_cash - cost_estimate) + action_done = True + else: + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_small_cash", + symbol=f"{symbol}.US", + diff=f"{diff:.2f}", + investable=f"{investable_cash:.2f}", + price=f"{price:.2f}", + ) if action_done: - formatted_logs = "\n".join(f" {log}" for log in [*logs, *skip_logs]) + cash_summary = translator( + "cash_summary", + available=f"{plan['available_cash']:.2f}", + investable=f"{plan['investable_cash']:.2f}", + ) + formatted_logs = "\n".join(f" {log}" for log in [*logs, *skip_logs, *note_logs]) tg_message = ( f"{translator('rebalance_title')}\n" f"{translator('market_status', status=plan['market_status'])}\n" + f"{cash_summary}\n" f"{translator('risk_position', ratio=plan['deploy_ratio_text'])}\n" f"{translator('income_target', ratio=plan['income_ratio_text'])}\n" f"{translator('income_locked', ratio=plan['income_locked_ratio_text'])}\n" @@ -230,6 +250,11 @@ def run_strategy( else: cash_label = translator("cash_label") equity_text = f"{plan['total_strategy_equity']:,.2f}" + cash_summary = translator( + "cash_summary", + available=f"{plan['available_cash']:.2f}", + investable=f"{plan['investable_cash']:.2f}", + ) holdings_lines = [] for row in plan["portfolio_rows"]: if len(row) == 1: @@ -248,6 +273,7 @@ def run_strategy( f"{translator('heartbeat_title')}\n" f"{translator('market_status', status=plan['market_status'])}\n" f"{translator('equity', value=equity_text)}\n" + f"{cash_summary}\n" f"{separator}\n" + "\n".join(holdings_lines) + "\n" f"{separator}\n" @@ -256,7 +282,7 @@ def run_strategy( f"{translator('income_locked', ratio=plan['income_locked_ratio_text'])}\n" f"{translator('heartbeat_signal', msg=plan['signal_message'])}\n" f"{separator}\n" - f"{translator('no_executable_orders') if skip_logs else translator('no_trades')}" + f"{translator('no_executable_orders') if (skip_logs or note_logs) else translator('no_trades')}" ) if skip_logs: no_trade_message += ( @@ -264,6 +290,12 @@ def run_strategy( f"{translator('skipped_actions')}\n" + "\n".join(f" {log}" for log in skip_logs) ) + if note_logs: + no_trade_message += ( + f"\n{separator}\n" + f"{translator('notes_title')}\n" + + "\n".join(f" {log}" for log in note_logs) + ) print(with_prefix(no_trade_message), flush=True) send_tg_message(no_trade_message) diff --git a/notifications/telegram.py b/notifications/telegram.py index a620aae..a83e2bd 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -15,17 +15,23 @@ "signal": "🎯 触发信号: {msg}", "heartbeat_title": "💓 【心跳检测】", "equity": "💰 净值: ${value}", + "cash_summary": "💵 账户现金: ${available} | 可投资现金: ${investable}", "cash_label": "现金", "heartbeat_signal": "🎯 信号: {msg}", "no_trades": "✅ 无需调仓", "no_executable_orders": "⚠️ 本轮没有可执行订单", "skipped_actions": "⚠️ 跳过项:", + "notes_title": "ℹ️ 说明:", "order_filled": "✅ 订单成交 | {symbol} {side} {qty}股 均价 ${price} (ID: {order_id})", "order_partial": "⚠️ 订单部分成交 | {symbol} {side} 已成交 {executed}/{qty}股 均价 ${price} (ID: {order_id})", "order_error": "❌ 订单异常 | {symbol} {side} {qty}股 已{status} (ID: {order_id}) 原因: {reason}", "error_title": "🚨 【策略异常】", "buy_skipped": "⚪️ [买入跳过] {detail}", "sell_skipped": "⚪️ [卖出跳过] {detail}", + "buy_deferred": "ℹ️ [买入说明] {detail}", + "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", + "buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})", + "buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}", "market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}", "limit_sell": "📉 [限价卖出] {symbol}: {qty}股 @ ${price}", @@ -47,17 +53,23 @@ "signal": "🎯 Signal: {msg}", "heartbeat_title": "💓 【Heartbeat】", "equity": "💰 Equity: ${value}", + "cash_summary": "💵 Cash: ${available} | Investable cash: ${investable}", "cash_label": "Cash", "heartbeat_signal": "🎯 Signal: {msg}", "no_trades": "✅ No trades needed", "no_executable_orders": "⚠️ No executable orders this cycle", "skipped_actions": "⚠️ Skipped actions:", + "notes_title": "ℹ️ Notes:", "order_filled": "✅ Order Filled | {symbol} {side} {qty} shares avg ${price} (ID: {order_id})", "order_partial": "⚠️ Partial Fill | {symbol} {side} filled {executed}/{qty} shares avg ${price} (ID: {order_id})", "order_error": "❌ Order Error | {symbol} {side} {qty} shares {status} (ID: {order_id}) reason: {reason}", "error_title": "🚨 【Strategy Error】", "buy_skipped": "⚪️ [Buy skipped] {detail}", "sell_skipped": "⚪️ [Sell skipped] {detail}", + "buy_deferred": "ℹ️ [Buy note] {detail}", + "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_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", "limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price}", "market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}", "limit_sell": "📉 [Limit sell] {symbol}: {qty} shares @ ${price}", diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index f67f8fc..2a62960 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -130,7 +130,7 @@ def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): self.assertEqual(len(sent_messages), 1) self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("限价卖出", sent_messages[0]) - self.assertIn("买入跳过", sent_messages[0]) + self.assertIn("买入说明", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): @@ -161,9 +161,75 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【心跳检测】", sent_messages[0]) self.assertIn("本轮没有可执行订单", sent_messages[0]) - self.assertIn("跳过项", sent_messages[0]) + self.assertIn("说明", sent_messages[0]) + self.assertIn("可投资现金", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) + def test_zero_investable_cash_is_silently_skipped(self): + plan = { + "strategy_assets": ["BOXX"], + "targets": {"BOXX": 27316.33}, + "market_values": {"BOXX": 24880.00}, + "sellable_quantities": {"BOXX": 214}, + "quantities": {"BOXX": 214}, + "current_min_trade": 100.0, + "limit_order_symbols": (), + "threshold_value": 100.0, + "investable_cash": 0.0, + "market_status": "🚀 RISK-ON (SOXL)", + "deploy_ratio_text": "57.7%", + "income_ratio_text": "0.0%", + "income_locked_ratio_text": "37.6%", + "signal_message": "SOXL 站上 150 日均线,持有 SOXL,交易层风险仓位 57.7%", + "available_cash": 3065.61, + "total_strategy_equity": 103350.09, + "portfolio_rows": (("BOXX",),), + } + + sent_messages, _, _ = self._run_strategy( + plan, + prices={"BOXX.US": 116.27}, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("账户现金", sent_messages[0]) + self.assertIn("可投资现金: $0.00", sent_messages[0]) + self.assertIn("✅ 无需调仓", sent_messages[0]) + self.assertNotIn("本轮没有可执行订单", sent_messages[0]) + self.assertNotIn("说明", sent_messages[0]) + self.assertNotIn("买入跳过", sent_messages[0]) + + def test_cash_limit_zero_mentions_possible_order_hold(self): + plan = { + "strategy_assets": ["SOXX"], + "targets": {"SOXX": 34718.05}, + "market_values": {"SOXX": 0.0}, + "sellable_quantities": {"SOXX": 0}, + "quantities": {"SOXX": 0}, + "current_min_trade": 100.0, + "limit_order_symbols": ("SOXX",), + "threshold_value": 100.0, + "investable_cash": 40000.0, + "market_status": "🛡️ DE-LEVER (SOXX)", + "deploy_ratio_text": "57.9%", + "income_ratio_text": "0.0%", + "income_locked_ratio_text": "38.3%", + "signal_message": "SOXL 跌破 150 日均线,切换至 SOXX,交易层风险仓位 57.9%", + "available_cash": 40000.0, + "total_strategy_equity": 60000.0, + "portfolio_rows": (("SOXX",),), + } + + sent_messages, _, _ = self._run_strategy( + plan, + prices={"SOXX.US": 322.74}, + estimate_max_purchase_quantity_value=0, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("券商估算可买数量为 0", sent_messages[0]) + self.assertIn("可能有未完成挂单", sent_messages[0]) + def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): initial_plan = { "strategy_assets": ["SOXL", "SOXX"],