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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ Telegram notifications include structured execution and heartbeat messages, with
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Set explicitly per deployment; enabled values include `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income` |
| `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) |
| `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. |
| `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `true`; set `false` to force whole-share sizing. |
| `LONGBRIDGE_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.000001` for fractional sizing. |
| `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `1.0`. |
| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. |
| `INCOME_THRESHOLD_USD` | No | Optional override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. |
| `QQQI_INCOME_RATIO` | No | Optional override for QQQI's share of the `tqqq_growth_income` income layer, 0–1. |
| `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) |
Expand Down Expand Up @@ -109,12 +113,12 @@ Recommended setup:
- Optional fallback only: `TELEGRAM_TOKEN`
- **GitHub Environment: `longbridge-hk`**
- Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME`
- Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`
- Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`
- Current live example: `STRATEGY_PROFILE=tech_communication_pullback_enhancement`
- Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk`
- **GitHub Environment: `longbridge-sg`**
- Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME`
- Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`
- Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`
- Current live example: `STRATEGY_PROFILE=soxl_soxx_trend_income`
- Recommended secret-name values: `longport-app-key-sg`, `longport-app-secret-sg`

Expand Down Expand Up @@ -210,6 +214,10 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换
| `STRATEGY_PROFILE` | 是 | 策略档位选择。每个部署都要显式设置;已启用值包括 `global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income` |
| `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) |
| `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 |
| `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `true`;设为 `false` 时强制按整数股计算。 |
| `LONGBRIDGE_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.000001` 表示碎股数量步进。 |
| `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `1.0`。 |
| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 |
| `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖。不填时使用策略包默认值。 |
| `QQQI_INCOME_RATIO` | 否 | 可选的 QQQI 收入层占比覆盖,0–1。 |
| `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) |
Expand Down Expand Up @@ -254,12 +262,12 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo
- 仅保留为 fallback:`TELEGRAM_TOKEN`
- **GitHub Environment: `longbridge-hk`**
- Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME`
- 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`
- 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`
- 当前线上示例:`STRATEGY_PROFILE=tech_communication_pullback_enhancement`
- 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk`
- **GitHub Environment: `longbridge-sg`**
- Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME`
- 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`
- 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`
- 当前线上示例:`STRATEGY_PROFILE=soxl_soxx_trend_income`
- 建议的 secret-name 值:`longport-app-key-sg`、`longport-app-secret-sg`

Expand Down
85 changes: 68 additions & 17 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from collections.abc import Mapping
from dataclasses import dataclass

from quant_platform_kit.common.quantity import (
floor_to_quantity_step,
format_quantity,
normalize_order_quantity,
)
from quant_platform_kit.common.models import OrderIntent


Expand Down Expand Up @@ -67,6 +72,14 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs):
print(with_prefix(message), flush=True)


def _is_whole_share_step(quantity_step) -> bool:
return float(quantity_step or 1.0) >= 1.0


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


def safe_quote_last_price(symbol, *, market_data_port, notify_issue):
try:
return float(market_data_port.get_quote(symbol).last_price)
Expand Down Expand Up @@ -119,6 +132,8 @@ def execute_rebalance_cycle(
dry_run_only=False,
post_sell_refresh_attempts=1,
post_sell_refresh_interval_sec=0.0,
quantity_step=1.0,
min_order_notional=0.0,
sleeper=_noop_sleep,
) -> ExecutionCycleResult:
logs: list[str] = []
Expand All @@ -140,6 +155,8 @@ def execute_rebalance_cycle(
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
investable_cash = float(execution["investable_cash"])
current_min_trade = float(execution["current_min_trade"])
order_quantity_step = float(quantity_step or 1.0)
minimum_order_notional = max(0.0, float(min_order_notional or 0.0))

def append_order_id_suffix(log_message, order_id):
order_id_text = str(order_id or "").strip()
Expand Down Expand Up @@ -224,18 +241,22 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
)
if price is None:
continue
quantity = min(
int(abs(diff) // price),
sellable_quantities[symbol],
quantity = _floor_order_quantity(
min(
floor_to_quantity_step(abs(diff) / price, order_quantity_step),
float(sellable_quantities[symbol]),
),
quantity_step=order_quantity_step,
)
if quantity > 0:
quantity_text = format_quantity(quantity)
if symbol in limit_order_symbols:
limit_price = round(price * limit_sell_discount, 2)
if dry_run_only:
submitted = record_dry_run(
f"{symbol}.US",
"sell",
quantity,
quantity_text,
limit_price,
order_type="limit",
)
Expand All @@ -245,15 +266,15 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
"limit",
"sell",
quantity,
translator("limit_sell", symbol=symbol, qty=quantity, price=limit_price),
translator("limit_sell", symbol=symbol, qty=quantity_text, price=limit_price),
submitted_price=limit_price,
)
else:
if dry_run_only:
submitted = record_dry_run(
f"{symbol}.US",
"sell",
quantity,
quantity_text,
round(price, 2),
order_type="market",
)
Expand All @@ -263,7 +284,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
"market",
"sell",
quantity,
translator("market_sell", symbol=symbol, qty=quantity, price=round(price, 2)),
translator("market_sell", symbol=symbol, qty=quantity_text, price=round(price, 2)),
)

if submitted:
Expand Down Expand Up @@ -349,12 +370,21 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
if price is None:
continue
can_buy_value = min(diff, investable_cash)
if can_buy_value > price:
if (
_is_whole_share_step(order_quantity_step)
and can_buy_value > price
) or (
not _is_whole_share_step(order_quantity_step)
and can_buy_value >= minimum_order_notional
):
is_limit_order = symbol in limit_order_symbols
order_kind = "limit" if is_limit_order else "market"
ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2)
budget_price = ref_price if is_limit_order else price
budget_quantity = int(can_buy_value // budget_price)
budget_quantity = floor_to_quantity_step(
can_buy_value / budget_price,
order_quantity_step,
)
cash_limit_quantity = estimate_cash_buy_quantity_safe(
trade_context,
f"{symbol}.US",
Expand All @@ -365,8 +395,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
)
if cash_limit_quantity is None:
continue

quantity = min(budget_quantity, cash_limit_quantity)
effective_cash_limit_quantity = float(cash_limit_quantity)
if (
not _is_whole_share_step(order_quantity_step)
and effective_cash_limit_quantity <= 0
):
effective_cash_limit_quantity = budget_quantity

quantity = _floor_order_quantity(
min(budget_quantity, effective_cash_limit_quantity),
quantity_step=order_quantity_step,
)
cost_estimate = 0.0
if quantity <= 0:
record_note_log(
Expand All @@ -376,16 +415,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
kind="buy_deferred_cash_limit",
symbol=f"{symbol}.US",
diff=f"{diff:.2f}",
budget_qty=budget_quantity,
budget_qty=format_quantity(budget_quantity),
)
continue

quantity_text = format_quantity(quantity)
if is_limit_order:
if dry_run_only:
submitted = record_dry_run(
f"{symbol}.US",
"buy",
quantity,
quantity_text,
ref_price,
order_type="limit",
)
Expand All @@ -395,7 +435,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
"limit",
"buy",
quantity,
translator("limit_buy", symbol=symbol, qty=quantity, price=ref_price),
translator("limit_buy", symbol=symbol, qty=quantity_text, price=ref_price),
submitted_price=ref_price,
)
cost_estimate = quantity * budget_price
Expand All @@ -404,7 +444,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
submitted = record_dry_run(
f"{symbol}.US",
"buy",
quantity,
quantity_text,
round(price, 2),
order_type="market",
)
Expand All @@ -414,14 +454,14 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
"market",
"buy",
quantity,
translator("market_buy", symbol=symbol, qty=quantity, price=round(price, 2)),
translator("market_buy", symbol=symbol, qty=quantity_text, price=round(price, 2)),
)
cost_estimate = quantity * budget_price

if submitted:
investable_cash = max(0, investable_cash - cost_estimate)
action_done = True
else:
elif _is_whole_share_step(order_quantity_step):
record_note_log(
note_logs,
translator=translator,
Expand All @@ -432,6 +472,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
investable=f"{investable_cash:.2f}",
price=f"{price:.2f}",
)
else:
record_note_log(
note_logs,
translator=translator,
with_prefix=with_prefix,
kind="buy_deferred_min_notional",
symbol=f"{symbol}.US",
diff=f"{diff:.2f}",
investable=f"{investable_cash:.2f}",
min_notional=f"{minimum_order_notional:.2f}",
)

return ExecutionCycleResult(
plan=dict(plan or {}),
Expand Down
2 changes: 2 additions & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ def fetch_replanned_state():
dry_run_only=config.dry_run_only,
post_sell_refresh_attempts=config.post_sell_refresh_attempts,
post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec,
quantity_step=config.quantity_step,
min_order_notional=config.min_order_notional,
sleeper=config.sleeper or _noop_sleep,
)
execution = execution_result.execution
Expand Down
2 changes: 1 addition & 1 deletion application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def build_execution_port(self, trade_context) -> ExecutionPort:
str(order_intent.symbol),
order_kind=str(order_intent.order_type),
side=str(order_intent.side),
quantity=int(order_intent.quantity),
quantity=float(order_intent.quantity),
submitted_price=order_intent.limit_price,
)
)
Expand Down
12 changes: 10 additions & 2 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ class LongBridgeRuntimeComposer:
limit_buy_premium: float
order_poll_interval_sec: int
order_poll_max_attempts: int
quantity_step: float
min_order_notional: float
dry_run_only: bool = False
broker_adapters: Any = None
strategy_adapters: Any = None
estimate_max_purchase_quantity_fn: Callable[..., int] | None = None
estimate_max_purchase_quantity_fn: Callable[..., float] | None = None
fetch_order_status_fn: Callable[..., Any] | None = None
fetch_token_from_secret_fn: Callable[..., str] | None = None
refresh_token_if_needed_fn: Callable[..., str] | None = None
Expand Down Expand Up @@ -168,6 +170,8 @@ def build_rebalance_config(self) -> LongBridgeRebalanceConfig:
dry_run_only=self.dry_run_only,
post_sell_refresh_attempts=self.order_poll_max_attempts,
post_sell_refresh_interval_sec=self.order_poll_interval_sec,
quantity_step=self.quantity_step,
min_order_notional=self.min_order_notional,
sleeper=self.sleeper,
)

Expand All @@ -194,10 +198,12 @@ def build_runtime_composer(
limit_buy_premium: float,
order_poll_interval_sec: int,
order_poll_max_attempts: int,
quantity_step: float,
min_order_notional: float,
dry_run_only: bool,
broker_adapters: Any,
strategy_adapters: Any,
estimate_max_purchase_quantity_fn: Callable[..., int],
estimate_max_purchase_quantity_fn: Callable[..., float],
fetch_order_status_fn: Callable[..., Any],
fetch_token_from_secret_fn: Callable[..., str],
refresh_token_if_needed_fn: Callable[..., str],
Expand Down Expand Up @@ -233,6 +239,8 @@ def build_runtime_composer(
limit_buy_premium=float(limit_buy_premium),
order_poll_interval_sec=int(order_poll_interval_sec),
order_poll_max_attempts=int(order_poll_max_attempts),
quantity_step=float(quantity_step),
min_order_notional=float(min_order_notional),
dry_run_only=bool(dry_run_only),
broker_adapters=broker_adapters,
strategy_adapters=strategy_adapters,
Expand Down
4 changes: 3 additions & 1 deletion application/runtime_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class LongBridgeRebalanceConfig:
dry_run_only: bool = False
post_sell_refresh_attempts: int = 1
post_sell_refresh_interval_sec: float = 0.0
quantity_step: float = 1.0
min_order_notional: float = 0.0
sleeper: Callable[[float], None] | None = None


Expand All @@ -28,7 +30,7 @@ class LongBridgeRebalanceRuntime:
bootstrap: Callable[[], tuple[Any, Any, Any]]
resolve_rebalance_plan: Callable[..., dict[str, Any]]
market_data_port_factory: Callable[[Any], MarketDataPort]
estimate_max_purchase_quantity: Callable[..., int]
estimate_max_purchase_quantity: Callable[..., float]
notifications: NotificationPort
notify_issue: Callable[[str, str], None]
portfolio_port_factory: Callable[[Any, Any], PortfolioPort]
Expand Down
Loading