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
180 changes: 106 additions & 74 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +68,7 @@ def run_strategy(

logs = []
skip_logs = []
note_logs = []
action_done = False
sell_submitted = False
threshold_value = plan["threshold_value"]
Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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"
Expand All @@ -256,14 +282,20 @@ 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 += (
f"\n{separator}\n"
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)

Expand Down
12 changes: 12 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand All @@ -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}",
Expand Down
70 changes: 68 additions & 2 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"],
Expand Down