Skip to content

Commit fbfb0b7

Browse files
committed
Surface material rebalance blockers
1 parent 54ed4d0 commit fbfb0b7

2 files changed

Lines changed: 125 additions & 5 deletions

File tree

application/execution_service.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -434,14 +434,25 @@ def execute_rebalance(
434434
)
435435
trade_logs.extend(_format_target_lines(target_weights, current_mv, equity, translator=translator))
436436

437+
missing_price_symbols: list[str] = []
438+
insufficient_buying_power_symbols: list[str] = []
439+
min_notional_symbols: list[str] = []
440+
quantity_zero_symbols: list[str] = []
441+
437442
has_sell_plan = False
438443
for symbol in all_symbols:
439444
current = current_mv.get(symbol, 0.0)
440445
target = target_mv.get(symbol, 0.0)
446+
if current <= target + threshold:
447+
continue
441448
price = prices.get(symbol)
442-
if price and current > target + threshold and int((current - target) / price) > 0:
449+
if not price:
450+
missing_price_symbols.append(symbol)
451+
continue
452+
if int((current - target) / price) > 0:
443453
has_sell_plan = True
444454
break
455+
quantity_zero_symbols.append(symbol)
445456

446457
anticipated_buying_power = get_available_buying_power(
447458
ib,
@@ -450,19 +461,54 @@ def execute_rebalance(
450461
has_buy_plan = False
451462
for symbol, target in target_mv.items():
452463
current = current_mv.get(symbol, 0.0)
464+
if current >= target - threshold:
465+
continue
453466
price = prices.get(symbol)
454-
if not price or current >= target - threshold:
467+
if not price:
468+
missing_price_symbols.append(symbol)
455469
continue
456470
buy_value = min(target - current, anticipated_buying_power * 0.95)
471+
if buy_value <= 0:
472+
insufficient_buying_power_symbols.append(symbol)
473+
continue
474+
if buy_value < 50:
475+
min_notional_symbols.append(symbol)
476+
continue
457477
limit_price = round(price * limit_buy_premium, 2)
458478
qty = int(buy_value / limit_price) if limit_price > 0 else 0
459-
if qty > 0 and buy_value >= 50:
479+
if qty > 0:
460480
has_buy_plan = True
461481
break
482+
quantity_zero_symbols.append(symbol)
462483

463484
if not has_sell_plan and not has_buy_plan:
464-
execution_summary["execution_status"] = "no_op"
465-
execution_summary["no_op_reason"] = "target_diff_below_threshold"
485+
reason = "target_diff_below_threshold"
486+
status = "no_op"
487+
if missing_price_symbols:
488+
symbols = ",".join(sorted(dict.fromkeys(missing_price_symbols)))
489+
reason = f"missing_price:{symbols}"
490+
status = "blocked"
491+
execution_summary["orders_skipped"].extend(
492+
{"symbol": symbol, "reason": "missing_price"}
493+
for symbol in sorted(dict.fromkeys(missing_price_symbols))
494+
)
495+
elif insufficient_buying_power_symbols:
496+
symbols = ",".join(sorted(dict.fromkeys(insufficient_buying_power_symbols)))
497+
reason = f"insufficient_buying_power:{symbols}"
498+
status = "blocked"
499+
elif min_notional_symbols:
500+
symbols = ",".join(sorted(dict.fromkeys(min_notional_symbols)))
501+
reason = f"min_notional:{symbols}"
502+
elif quantity_zero_symbols:
503+
symbols = ",".join(sorted(dict.fromkeys(quantity_zero_symbols)))
504+
reason = f"quantity_zero:{symbols}"
505+
506+
execution_summary["execution_status"] = status
507+
execution_summary["no_op_reason"] = reason
508+
if reason != "target_diff_below_threshold":
509+
execution_summary["skipped_reasons"].append(reason)
510+
if status == "blocked":
511+
trade_logs.append(translator("failed", reason=reason))
466512
return _finalize_result(trade_logs, execution_summary, return_summary=return_summary)
467513

468514
same_day_filled_symbols = _collect_same_day_filled_symbols(ib, set(all_symbols), trade_date)

tests/test_execution_service.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,77 @@ def fake_fetch_quote_snapshots(_ib, symbols):
290290
assert summary["safe_haven_symbol"] == "BOXX"
291291
assert summary["orders_submitted"]
292292
assert summary["target_vs_current"]
293+
294+
295+
def test_execute_rebalance_blocks_when_material_target_has_missing_prices():
296+
class FakeIB:
297+
def openTrades(self):
298+
return []
299+
300+
def fills(self):
301+
return []
302+
303+
def accountValues(self):
304+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="5000")]
305+
306+
trade_logs, summary = execute_rebalance(
307+
FakeIB(),
308+
{"VOO": 1.0},
309+
{},
310+
{"equity": 1000.0, "buying_power": 1000.0},
311+
fetch_quote_snapshots=lambda *_args, **_kwargs: {},
312+
submit_order_intent=lambda *_args, **_kwargs: None,
313+
order_intent_cls=OrderIntent,
314+
translator=translate,
315+
strategy_symbols=["VOO"],
316+
strategy_profile="tech_pullback_cash_buffer",
317+
signal_metadata={"trade_date": "2026-04-01", "snapshot_as_of": "2026-03-31"},
318+
dry_run_only=True,
319+
cash_reserve_ratio=0.0,
320+
rebalance_threshold_ratio=0.02,
321+
limit_buy_premium=1.005,
322+
sell_settle_delay_sec=0,
323+
return_summary=True,
324+
)
325+
326+
assert summary["execution_status"] == "blocked"
327+
assert summary["no_op_reason"] == "missing_price:VOO"
328+
assert summary["orders_skipped"] == [{"symbol": "VOO", "reason": "missing_price"}]
329+
assert "failed missing_price:VOO" in trade_logs[-1]
330+
331+
332+
def test_execute_rebalance_blocks_when_material_target_has_no_buying_power():
333+
class FakeIB:
334+
def openTrades(self):
335+
return []
336+
337+
def fills(self):
338+
return []
339+
340+
def accountValues(self):
341+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="0")]
342+
343+
trade_logs, summary = execute_rebalance(
344+
FakeIB(),
345+
{"VOO": 1.0},
346+
{},
347+
{"equity": 1000.0, "buying_power": 0.0},
348+
fetch_quote_snapshots=lambda *_args, **_kwargs: {"VOO": SimpleNamespace(last_price=100.0)},
349+
submit_order_intent=lambda *_args, **_kwargs: None,
350+
order_intent_cls=OrderIntent,
351+
translator=translate,
352+
strategy_symbols=["VOO"],
353+
strategy_profile="tech_pullback_cash_buffer",
354+
signal_metadata={"trade_date": "2026-04-01", "snapshot_as_of": "2026-03-31"},
355+
dry_run_only=True,
356+
cash_reserve_ratio=0.0,
357+
rebalance_threshold_ratio=0.02,
358+
limit_buy_premium=1.005,
359+
sell_settle_delay_sec=0,
360+
return_summary=True,
361+
)
362+
363+
assert summary["execution_status"] == "blocked"
364+
assert summary["no_op_reason"] == "insufficient_buying_power:VOO"
365+
assert summary["skipped_reasons"] == ["insufficient_buying_power:VOO"]
366+
assert "failed insufficient_buying_power:VOO" in trade_logs[-1]

0 commit comments

Comments
 (0)