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
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +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. 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`. |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for limit buys; 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. |
| `IB_ACCOUNT_GROUP_CONFIG_JSON` | No | Local/dev JSON fallback for account-group config. Not recommended for production Cloud Run. |
| `TELEGRAM_TOKEN` | Yes | Telegram bot token. For Cloud Run, prefer a Secret Manager reference instead of a literal env var. |
Expand Down Expand Up @@ -160,9 +158,7 @@ IBKR_FEATURE_SNAPSHOT_PATH=/var/data/tech_communication_pullback_enhancement_fea
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=/var/manifests/tech_communication_pullback_enhancement_feature_snapshot_latest.csv.manifest.json
# IBKR_STRATEGY_CONFIG_PATH is optional; the bundled canonical default is used when unset.
IBKR_DRY_RUN_ONLY=true
# Keep whole-share sizing unless fractional API support has been verified for this account/API path.
# IBKR_FRACTIONAL_SHARES_ENABLED=true
# IBKR_ORDER_QUANTITY_STEP=0.0001
# IBKR orders run on whole shares only.
GLOBAL_TELEGRAM_CHAT_ID=<telegram-chat-id>
NOTIFY_LANG=zh
```
Expand Down Expand Up @@ -343,9 +339,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`。四舍五入后低于约 `0.01` 股的订单会跳过,因为 IBKR API 会拒单。 |
| `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `50.0`。 |
| `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 限价买入的最小名义金额;默认 `50.0`。 |
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |
| `IB_ACCOUNT_GROUP_CONFIG_JSON` | 否 | 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 |
| `TELEGRAM_TOKEN` | 是 | Telegram 机器人 Token。Cloud Run 上更推荐走 Secret Manager 引用,不要直接写成明文 env。 |
Expand Down
72 changes: 0 additions & 72 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@
)


MIN_FRACTIONAL_ORDER_QUANTITY = 0.01


def get_market_prices(
ib,
symbols,
Expand Down Expand Up @@ -433,10 +430,6 @@ 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 @@ -549,7 +542,6 @@ 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 @@ -658,7 +650,6 @@ 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 @@ -709,18 +700,6 @@ 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 @@ -769,18 +748,6 @@ 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",
"quantity": qty,
"minimum_quantity": minimum_supported_quantity,
}
)
continue
has_buy_plan = True
break
quantity_zero_symbols.append(symbol)
Expand All @@ -803,9 +770,6 @@ 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 @@ -914,18 +878,6 @@ 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 @@ -941,18 +893,6 @@ 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 @@ -1028,18 +968,6 @@ 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
9 changes: 1 addition & 8 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
first_non_empty,
resolve_bool_value,
resolve_float_env,
resolve_quantity_step_env,
resolve_strategy_runtime_path_settings,
)
from quant_platform_kit.common.runtime_target import (
Expand Down Expand Up @@ -146,13 +145,7 @@ def load_platform_runtime_settings(
strategy_config_source=runtime_paths.strategy_config_source,
reconciliation_output_path=runtime_paths.reconciliation_output_path,
dry_run_only=resolve_bool_value(os.getenv("IBKR_DRY_RUN_ONLY")),
quantity_step=resolve_quantity_step_env(
os.environ,
step_env="IBKR_ORDER_QUANTITY_STEP",
fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED",
fractional_default=False,
fractional_step=0.0001,
),
quantity_step=1.0,
min_order_notional=resolve_float_env(
os.environ,
"IBKR_MIN_ORDER_NOTIONAL_USD",
Expand Down
110 changes: 0 additions & 110 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from types import SimpleNamespace
import math

from application.execution_service import check_order_submitted, execute_rebalance
from quant_platform_kit.common.models import OrderIntent
Expand Down Expand Up @@ -213,115 +212,6 @@ def accountValues(self):
raise AssertionError("expected ValueError")


def test_execute_rebalance_can_submit_fractional_buy_when_quantity_step_allows(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=500.0) for symbol in symbols}

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

_trade_logs, summary = execute_rebalance(
FakeIB(),
{"VOO": 0.15},
{},
{"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=["VOO"],
strategy_profile="global_etf_rotation",
signal_metadata=_signal_metadata({"VOO": 0.15}, risk_symbols=("VOO",), trade_date="2026-04-01"),
dry_run_only=False,
cash_reserve_ratio=0.0,
rebalance_threshold_ratio=0.02,
limit_buy_premium=1.005,
quantity_step=0.0001,
min_order_notional=50.0,
sell_settle_delay_sec=0,
execution_lock_dir=tmp_path,
return_summary=True,
)

assert summary["execution_status"] == "executed"
assert len(submitted) == 1
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
5 changes: 2 additions & 3 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,15 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo
assert settings.notify_lang == "zh"


def test_load_platform_runtime_settings_supports_fractional_quantity_step(monkeypatch):
def test_load_platform_runtime_settings_uses_whole_share_quantity_step(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE))
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)
monkeypatch.setenv("IBKR_FRACTIONAL_SHARES_ENABLED", "true")
monkeypatch.setenv("IBKR_MIN_ORDER_NOTIONAL_USD", "5")

settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

assert settings.quantity_step == 0.0001
assert settings.quantity_step == 1.0
assert settings.min_order_notional == 5.0


Expand Down