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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific i
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced` |
| `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_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. |
| `IBKR_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.000001` 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. |
| `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 @@ -346,6 +349,10 @@ IBKR 账户
| `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 |
| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` |
| `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_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。 |
| `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.000001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 |
| `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
40 changes: 35 additions & 5 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,31 @@ def _floor_order_quantity(quantity, *, quantity_step):
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))


def _sell_order_quantity(
*,
current_value,
target_value,
price,
position_quantity,
quantity_step,
):
held_quantity = max(0.0, float(position_quantity or 0.0))
if held_quantity <= 0.0:
return 0

target = max(0.0, float(target_value or 0.0))
if target <= 0.0:
return _floor_order_quantity(held_quantity, quantity_step=quantity_step)

sell_value = max(0.0, float(current_value or 0.0) - target)
if sell_value <= 0.0 or float(price or 0.0) <= 0.0:
return 0
return _floor_order_quantity(
min(sell_value / float(price), held_quantity),
quantity_step=quantity_step,
)


def _finalize_result(trade_logs, execution_summary, *, return_summary: bool):
if return_summary:
return trade_logs, execution_summary
Expand Down Expand Up @@ -557,8 +582,11 @@ def execute_rebalance(
if not price:
missing_price_symbols.append(symbol)
continue
qty = _floor_order_quantity(
(current - target) / price,
qty = _sell_order_quantity(
current_value=current,
target_value=target,
price=price,
position_quantity=positions.get(symbol, {}).get("quantity", 0),
quantity_step=order_quantity_step,
)
if qty > 0:
Expand Down Expand Up @@ -702,14 +730,16 @@ def execute_rebalance(
current = current_mv.get(symbol, 0)
target = target_mv.get(symbol, 0)
if current > target + threshold:
sell_value = current - target
price = prices.get(symbol)
if not price:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"})
execution_summary["skipped_reasons"].append(f"missing_price:{symbol}")
continue
qty = _floor_order_quantity(
sell_value / price,
qty = _sell_order_quantity(
current_value=current,
target_value=target,
price=price,
position_quantity=positions.get(symbol, {}).get("quantity", 0),
quantity_step=order_quantity_step,
)
if qty <= 0:
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c24d4c52e84c8c696006532590b15e9be92c8d89
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c0cf04f002fd6348c9af7ebd95c9c0ad03c36bcd
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c9ec484c9a12cdffedf7d87c8906b93b21f50b1c
pandas
numpy
requests
Expand Down
41 changes: 9 additions & 32 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from quant_platform_kit.common.runtime_config import (
first_non_empty,
resolve_bool_value,
resolve_float_env,
resolve_quantity_step_env,
resolve_strategy_runtime_path_settings,
)
from strategy_registry import (
Expand Down Expand Up @@ -137,12 +139,17 @@ 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=_quantity_step_env(
quantity_step=resolve_quantity_step_env(
os.environ,
step_env="IBKR_ORDER_QUANTITY_STEP",
fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED",
fractional_default=False,
),
min_order_notional=_float_env("IBKR_MIN_ORDER_NOTIONAL_USD", default=50.0),
min_order_notional=resolve_float_env(
os.environ,
"IBKR_MIN_ORDER_NOTIONAL_USD",
default=50.0,
),
account_group=account_group,
service_name=group_config.service_name,
account_ids=group_config.account_ids,
Expand All @@ -159,36 +166,6 @@ def resolve_strategy_profile(raw_value: str | None) -> str:
).profile


def _optional_float_env(name: str) -> float | None:
raw_value = os.getenv(name)
if raw_value is None or raw_value.strip() == "":
return None
return float(raw_value)


def _float_env(name: str, *, default: float) -> float:
value = _optional_float_env(name)
return float(default) if value is None else value


def _quantity_step_env(
*,
step_env: str,
fractional_env: str,
fractional_default: bool,
) -> float:
explicit_step = _optional_float_env(step_env)
if explicit_step is not None:
return explicit_step
raw_enabled = os.getenv(fractional_env)
fractional_enabled = (
fractional_default
if raw_enabled is None
else resolve_bool_value(raw_enabled)
)
return 0.000001 if fractional_enabled else 1.0


def resolve_account_group(raw_value: str | None) -> str:
value = (raw_value or "").strip()
if not value:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,53 @@ def fake_fetch_quote_snapshots(_ib, symbols):
assert math.isclose(submitted[0].quantity, 0.298507, rel_tol=0.0, abs_tol=0.000001)


def test_execute_rebalance_zero_target_sell_uses_position_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")

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

_trade_logs, summary = execute_rebalance(
FakeIB(),
{"VOO": 0.0},
{"VOO": {"quantity": 2}},
{"equity": 327.88, "buying_power": 1000.0},
fetch_quote_snapshots=lambda *_args, **_kwargs: {"VOO": SimpleNamespace(last_price=165.85)},
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.0}, 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=1.0,
sell_settle_delay_sec=0,
execution_lock_dir=tmp_path,
return_summary=True,
)

assert summary["execution_status"] == "executed"
assert len(submitted) == 1
assert submitted[0].side == "sell"
assert submitted[0].quantity == 2


def test_execute_rebalance_skips_when_pending_orders_exist():
class FakeIB:
def openTrades(self):
Expand Down