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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). |
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. |
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. Orders that floor below roughly `0.01` shares are skipped because the IBKR API rejects them. |
| `IBKR_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.0001` for fractional sizing. Takes precedence over `IBKR_FRACTIONAL_SHARES_ENABLED`. |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. |
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. |
Expand Down Expand Up @@ -343,7 +343,7 @@ IBKR 账户
| `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 |
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 |
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 |
| `IBKR_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。 |
| `IBKR_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。四舍五入后低于约 `0.01` 股的订单会跳过,因为 IBKR API 会拒单。 |
| `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `50.0`。 |
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |
Expand Down
72 changes: 72 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
)


MIN_FRACTIONAL_ORDER_QUANTITY = 0.01


def get_market_prices(
ib,
symbols,
Expand Down Expand Up @@ -430,6 +433,10 @@ def _floor_order_quantity(quantity, *, quantity_step):
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))


def _minimum_supported_order_quantity(quantity_step: float) -> float:
return 1.0 if float(quantity_step or 1.0) >= 1.0 else MIN_FRACTIONAL_ORDER_QUANTITY


def _sell_order_quantity(
*,
current_value,
Expand Down Expand Up @@ -542,6 +549,7 @@ def execute_rebalance(
threshold = equity * rebalance_threshold_ratio
order_quantity_step = float(quantity_step or 1.0)
minimum_order_notional = max(0.0, float(min_order_notional or 0.0))
minimum_supported_quantity = _minimum_supported_order_quantity(order_quantity_step)
execution_summary["cash_reserve_dollars"] = float(reserved)

all_symbols = set(target_weights.keys()) | set(positions.keys())
Expand Down Expand Up @@ -650,6 +658,7 @@ def execute_rebalance(
insufficient_buying_power_symbols: list[str] = []
min_notional_symbols: list[str] = []
quantity_zero_symbols: list[str] = []
fractional_quantity_too_small_symbols: list[str] = []
anticipated_buying_power = get_available_buying_power(
ib,
account_values.get("buying_power", 0),
Expand Down Expand Up @@ -700,6 +709,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
quantity_step=order_quantity_step,
)
if qty > 0:
if qty < minimum_supported_quantity:
fractional_quantity_too_small_symbols.append(symbol)
execution_summary["orders_skipped"].append(
{
"symbol": symbol,
"side": "sell",
"reason": "fractional_quantity_too_small",
"quantity": qty,
"minimum_quantity": minimum_supported_quantity,
}
)
continue
has_sell_plan = True
break
quantity_zero_symbols.append(symbol)
Expand Down Expand Up @@ -748,6 +769,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
else 0
)
if qty > 0:
if qty < minimum_supported_quantity:
fractional_quantity_too_small_symbols.append(symbol)
execution_summary["orders_skipped"].append(
{
"symbol": symbol,
"side": "buy",
"reason": "fractional_quantity_too_small",
Comment on lines +774 to +778

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer fractional-skip logging until execution phase

Logging orders_skipped in the planning pass causes duplicate skip records whenever at least one other symbol makes the rebalance executable. In that case this symbol is appended here, then appended again in the later buy/sell execution loops when it is re-evaluated and skipped for the same reason, which inflates skip counts and can mislead downstream monitoring/reporting that treats each entry as a distinct skipped order.

Useful? React with 👍 / 👎.

"quantity": qty,
"minimum_quantity": minimum_supported_quantity,
}
)
continue
has_buy_plan = True
break
quantity_zero_symbols.append(symbol)
Expand All @@ -770,6 +803,9 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
elif min_notional_symbols:
symbols = ",".join(sorted(dict.fromkeys(min_notional_symbols)))
reason = f"min_notional:{symbols}"
elif fractional_quantity_too_small_symbols:
symbols = ",".join(sorted(dict.fromkeys(fractional_quantity_too_small_symbols)))
reason = f"fractional_quantity_too_small:{symbols}"
elif quantity_zero_symbols:
symbols = ",".join(sorted(dict.fromkeys(quantity_zero_symbols)))
reason = f"quantity_zero:{symbols}"
Expand Down Expand Up @@ -878,6 +914,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
if qty <= 0:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
continue
if qty < minimum_supported_quantity:
execution_summary["orders_skipped"].append(
{
"symbol": symbol,
"side": "sell",
"reason": "fractional_quantity_too_small",
"quantity": qty,
"minimum_quantity": minimum_supported_quantity,
}
)
execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}")
continue
elif current > target + threshold:
if not price:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"})
Expand All @@ -893,6 +941,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
if qty <= 0:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
continue
if qty < minimum_supported_quantity:
execution_summary["orders_skipped"].append(
{
"symbol": symbol,
"side": "sell",
"reason": "fractional_quantity_too_small",
"quantity": qty,
"minimum_quantity": minimum_supported_quantity,
}
)
execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}")
continue
else:
continue

Expand Down Expand Up @@ -968,6 +1028,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
if qty <= 0:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"})
continue
if qty < minimum_supported_quantity:
execution_summary["orders_skipped"].append(
{
"symbol": symbol,
"side": "buy",
"reason": "fractional_quantity_too_small",
"quantity": qty,
"minimum_quantity": minimum_supported_quantity,
}
)
execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}")
continue

if dry_run_only:
execution_summary["orders_submitted"].append(
Expand Down
59 changes: 59 additions & 0 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,65 @@ def fake_fetch_quote_snapshots(_ib, symbols):
assert math.isclose(submitted[0].quantity, 0.2985, rel_tol=0.0, abs_tol=1e-9)


def test_execute_rebalance_skips_fractional_orders_below_ibkr_minimum_quantity(monkeypatch, tmp_path):
class FakeIB:
def openTrades(self):
return []

def fills(self):
return []

def accountValues(self):
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1000")]

submitted = []

def fake_submit_order_intent(_ib, intent):
submitted.append(intent)
return SimpleNamespace(broker_order_id="1", status="Submitted")

def fake_fetch_quote_snapshots(_ib, symbols):
return {symbol: SimpleNamespace(last_price=724.32) for symbol in symbols}

monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)

_trade_logs, summary = execute_rebalance(
FakeIB(),
{"QQQ": 0.002},
{},
{"equity": 1000.0, "buying_power": 1000.0},
fetch_quote_snapshots=fake_fetch_quote_snapshots,
submit_order_intent=fake_submit_order_intent,
order_intent_cls=OrderIntent,
translator=translate,
strategy_symbols=["QQQ"],
strategy_profile="global_etf_rotation",
signal_metadata=_signal_metadata({"QQQ": 0.002}, risk_symbols=("QQQ",), trade_date="2026-04-01"),
dry_run_only=False,
cash_reserve_ratio=0.0,
rebalance_threshold_ratio=0.0,
limit_buy_premium=1.005,
quantity_step=0.0001,
min_order_notional=1.0,
sell_settle_delay_sec=0,
execution_lock_dir=tmp_path,
return_summary=True,
)

assert summary["execution_status"] == "no_op"
assert summary["no_op_reason"] == "fractional_quantity_too_small:QQQ"
assert summary["orders_skipped"] == [
{
"symbol": "QQQ",
"side": "buy",
"reason": "fractional_quantity_too_small",
"quantity": 0.0027,
"minimum_quantity": 0.01,
}
]
assert submitted == []


def test_execute_rebalance_zero_target_sell_uses_position_quantity(monkeypatch, tmp_path):
class FakeIB:
def openTrades(self):
Expand Down