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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ 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.000001
GLOBAL_TELEGRAM_CHAT_ID=<telegram-chat-id>
NOTIFY_LANG=zh
```
Expand Down
53 changes: 40 additions & 13 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
from typing import Any

import pandas as pd
from quant_platform_kit.common.quantity import (
floor_to_quantity_step,
format_quantity,
normalize_order_quantity,
)


def get_market_prices(
Expand All @@ -36,7 +41,7 @@ def check_order_submitted(report, *, translator):
"order_filled",
symbol=report.symbol,
side=report.side,
qty=int(report.filled_quantity or report.quantity),
qty=format_quantity(report.filled_quantity or report.quantity),
price=f"{float(report.average_fill_price or 0.0):.2f}",
order_id=order_id,
),
Expand All @@ -48,8 +53,8 @@ def check_order_submitted(report, *, translator):
"order_partial",
symbol=report.symbol,
side=report.side,
executed=int(report.filled_quantity or 0),
qty=int(report.quantity or 0),
executed=format_quantity(report.filled_quantity or 0),
qty=format_quantity(report.quantity or 0),
price=f"{float(report.average_fill_price or 0.0):.2f}",
order_id=order_id,
),
Expand Down Expand Up @@ -348,6 +353,10 @@ def _build_target_diff_rows(
return rows


def _floor_order_quantity(quantity, *, quantity_step):
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))


def _finalize_result(trade_logs, execution_summary, *, return_summary: bool):
if return_summary:
return trade_logs, execution_summary
Expand Down Expand Up @@ -375,6 +384,8 @@ def execute_rebalance(
rebalance_threshold_ratio,
limit_buy_premium,
sell_settle_delay_sec,
quantity_step=1.0,
min_order_notional=50.0,
execution_lock_dir=None,
return_summary=False,
):
Expand Down Expand Up @@ -425,6 +436,8 @@ def execute_rebalance(
reserved = equity * cash_reserve_ratio
investable = equity - reserved
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))
execution_summary["cash_reserve_dollars"] = float(reserved)

all_symbols = set(target_weights.keys()) | set(positions.keys())
Expand Down Expand Up @@ -544,7 +557,11 @@ def execute_rebalance(
if not price:
missing_price_symbols.append(symbol)
continue
if int((current - target) / price) > 0:
qty = _floor_order_quantity(
(current - target) / price,
quantity_step=order_quantity_step,
)
if qty > 0:
has_sell_plan = True
break
quantity_zero_symbols.append(symbol)
Expand All @@ -566,11 +583,15 @@ def execute_rebalance(
if buy_value <= 0:
insufficient_buying_power_symbols.append(symbol)
continue
if buy_value < 50:
if buy_value < minimum_order_notional:
min_notional_symbols.append(symbol)
continue
limit_price = round(price * limit_buy_premium, 2)
qty = int(buy_value / limit_price) if limit_price > 0 else 0
qty = (
_floor_order_quantity(buy_value / limit_price, quantity_step=order_quantity_step)
if limit_price > 0
else 0
)
if qty > 0:
has_buy_plan = True
break
Expand Down Expand Up @@ -687,7 +708,10 @@ def execute_rebalance(
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"})
execution_summary["skipped_reasons"].append(f"missing_price:{symbol}")
continue
qty = int(sell_value / price)
qty = _floor_order_quantity(
sell_value / price,
quantity_step=order_quantity_step,
)
if qty <= 0:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
continue
Expand All @@ -696,7 +720,7 @@ def execute_rebalance(
execution_summary["orders_submitted"].append(
{"symbol": symbol, "side": "sell", "quantity": qty, "status": "dry_run"}
)
trade_logs.append(f"DRY_RUN sell {symbol} {qty}")
trade_logs.append(f"DRY_RUN sell {symbol} {format_quantity(qty)}")
continue
report = submit_order_intent(
ib,
Expand All @@ -720,7 +744,7 @@ def execute_rebalance(
else:
execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}")
trade_logs.append(translator("market_sell", symbol=symbol, qty=qty) + f" {status_msg}")
trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}")
if ok:
sell_executed = True

Expand All @@ -741,12 +765,15 @@ def execute_rebalance(
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "missing_price"})
execution_summary["skipped_reasons"].append(f"missing_price:{symbol}")
continue
if buy_value < 50:
if buy_value < minimum_order_notional:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "min_notional"})
continue

limit_price = round(price * limit_buy_premium, 2)
qty = int(buy_value / limit_price)
qty = _floor_order_quantity(
buy_value / limit_price,
quantity_step=order_quantity_step,
)
if qty <= 0:
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"})
continue
Expand All @@ -761,7 +788,7 @@ def execute_rebalance(
"status": "dry_run",
}
)
trade_logs.append(f"DRY_RUN buy {symbol} {qty} @{limit_price:.2f}")
trade_logs.append(f"DRY_RUN buy {symbol} {format_quantity(qty)} @{limit_price:.2f}")
buying_power -= qty * limit_price
continue
report = submit_order_intent(
Expand Down Expand Up @@ -795,7 +822,7 @@ def execute_rebalance(
execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}")
trade_logs.append(
translator("limit_buy", symbol=symbol, qty=qty, price=f"{limit_price:.2f}") + f" {status_msg}"
translator("limit_buy", symbol=symbol, qty=format_quantity(qty), price=f"{limit_price:.2f}") + f" {status_msg}"
)
if ok:
buying_power -= qty * limit_price
Expand Down
9 changes: 5 additions & 4 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from notifications.events import NotificationPublisher
from notifications import renderers as notification_renderers
from quant_platform_kit.common.models import PortfolioSnapshot, Position
from quant_platform_kit.common.quantity import format_quantity
from quant_platform_kit.common.notification_localization import (
localize_notification_text as _base_localize_notification_text,
translator_uses_zh as _base_translator_uses_zh,
Expand Down Expand Up @@ -180,9 +181,9 @@ def _summarize_orders(orders, *, limit: int = 3) -> str:
preview = []
for order in orders[:limit]:
symbol = str(order.get("symbol") or "").strip().upper()
quantity = int(order.get("quantity") or 0)
quantity = float(order.get("quantity") or 0.0)
if symbol and quantity > 0:
preview.append(f"{symbol} {quantity}")
preview.append(f"{symbol} {format_quantity(quantity)}")
elif symbol:
preview.append(symbol)
remaining = len(orders) - len(preview)
Expand Down Expand Up @@ -378,7 +379,7 @@ def build_dashboard(
qty = positions[symbol]["quantity"]
avg = positions[symbol]["avg_cost"]
market_value = qty * avg
position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}")
position_lines.append(f" - {symbol}: {format_quantity(qty)}股 | ${market_value:,.2f}")
position_text = "\n".join(position_lines) if position_lines else translator("empty_positions")
allocation = _resolve_weight_allocation(signal_metadata, required=False)
target_lines = []
Expand Down Expand Up @@ -499,7 +500,7 @@ def _snapshot_to_portfolio_view(snapshot) -> tuple[dict[str, dict[str, float | i
positions = {}
for position in getattr(snapshot, "positions", ()) or ():
positions[str(position.symbol).strip().upper()] = {
"quantity": int(position.quantity),
"quantity": float(position.quantity),
"avg_cost": float(position.average_cost or 0.0),
}
account_values = {
Expand Down
10 changes: 9 additions & 1 deletion application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class IBKRRuntimeBrokerAdapters:
cash_reserve_ratio: float
rebalance_threshold_ratio: float
limit_buy_premium: float
quantity_step: float
min_order_notional: float
sell_settle_delay_sec: float
separator: str
strategy_display_name: str
Expand Down Expand Up @@ -84,7 +86,7 @@ def get_current_portfolio(self, ib):
positions = {}
for position in snapshot.positions:
positions[position.symbol] = {
"quantity": int(position.quantity),
"quantity": float(position.quantity),
"avg_cost": float(position.average_cost or 0.0),
}
account_values = {
Expand Down Expand Up @@ -151,6 +153,8 @@ def execute_rebalance(
cash_reserve_ratio=self.cash_reserve_ratio,
rebalance_threshold_ratio=self.rebalance_threshold_ratio,
limit_buy_premium=self.limit_buy_premium,
quantity_step=self.quantity_step,
min_order_notional=self.min_order_notional,
sell_settle_delay_sec=self.sell_settle_delay_sec,
return_summary=True,
)
Expand Down Expand Up @@ -234,6 +238,8 @@ def build_runtime_broker_adapters(
cash_reserve_ratio: float,
rebalance_threshold_ratio: float,
limit_buy_premium: float,
quantity_step: float,
min_order_notional: float,
sell_settle_delay_sec: float,
separator: str,
strategy_display_name: str,
Expand Down Expand Up @@ -267,6 +273,8 @@ def build_runtime_broker_adapters(
cash_reserve_ratio=float(cash_reserve_ratio),
rebalance_threshold_ratio=float(rebalance_threshold_ratio),
limit_buy_premium=float(limit_buy_premium),
quantity_step=float(quantity_step),
min_order_notional=float(min_order_notional),
sell_settle_delay_sec=float(sell_settle_delay_sec),
separator=str(separator or ""),
strategy_display_name=str(strategy_display_name or ""),
Expand Down
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ def build_broker_adapters():
cash_reserve_ratio=CASH_RESERVE_RATIO,
rebalance_threshold_ratio=REBALANCE_THRESHOLD_RATIO,
limit_buy_premium=LIMIT_BUY_PREMIUM,
quantity_step=RUNTIME_SETTINGS.quantity_step,
min_order_notional=RUNTIME_SETTINGS.min_order_notional,
sell_settle_delay_sec=SELL_SETTLE_DELAY_SEC,
separator=SEPARATOR,
strategy_display_name=strategy_display_name,
Expand Down
7 changes: 4 additions & 3 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re

from notifications.events import RenderedNotification
from quant_platform_kit.common.quantity import format_quantity
from quant_platform_kit.common.notification_localization import (
localize_notification_text as _base_localize_notification_text,
translator_uses_zh as _base_translator_uses_zh,
Expand Down Expand Up @@ -118,9 +119,9 @@ def _summarize_orders(orders, *, limit: int = 3) -> str:
preview = []
for order in orders[:limit]:
symbol = str(order.get("symbol") or "").strip().upper()
quantity = int(order.get("quantity") or 0)
quantity = float(order.get("quantity") or 0.0)
if symbol and quantity > 0:
preview.append(f"{symbol} {quantity}")
preview.append(f"{symbol} {format_quantity(quantity)}")
elif symbol:
preview.append(symbol)
remaining = len(orders) - len(preview)
Expand Down Expand Up @@ -316,7 +317,7 @@ def build_dashboard(
qty = positions[symbol]["quantity"]
avg = positions[symbol]["avg_cost"]
market_value = qty * avg
position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}")
position_lines.append(f" - {symbol}: {format_quantity(qty)}股 | ${market_value:,.2f}")
position_text = "\n".join(position_lines) if position_lines else translator("empty_positions")
allocation = _resolve_weight_allocation(signal_metadata, required=False)
target_lines = []
Expand Down
50 changes: 44 additions & 6 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ class PlatformRuntimeSettings:
strategy_config_source: str | None
reconciliation_output_path: str | None
dry_run_only: bool
account_group: str
service_name: str | None
account_ids: tuple[str, ...]
tg_token: str | None
tg_chat_id: str | None
notify_lang: str
quantity_step: float = 1.0
min_order_notional: float = 50.0
account_group: str = DEFAULT_ACCOUNT_GROUP
service_name: str | None = None
account_ids: tuple[str, ...] = ()
tg_token: str | None = None
tg_chat_id: str | None = None
notify_lang: str = "en"


def load_platform_runtime_settings(
Expand Down Expand Up @@ -135,6 +137,12 @@ 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(
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),
account_group=account_group,
service_name=group_config.service_name,
account_ids=group_config.account_ids,
Expand All @@ -151,6 +159,36 @@ 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
Loading