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
1 change: 0 additions & 1 deletion .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ jobs:
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}
LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET: ${{ vars.LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET }}
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
steps:
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,14 @@ Telegram notifications include structured execution and heartbeat messages, with
| `STRATEGY_PROFILE` | Yes | Strategy profile selector for compatibility and strategy routing. Set explicitly per deployment; enabled values include `global_etf_confidence_vol_gate`, `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`. The structured runtime target is carried separately as `RUNTIME_TARGET_JSON`. |
| `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `PAPER`, `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_LIMIT_BUY_FALLBACK_TO_MARKET` | No | Set to `true` to convert fractional `limit buy` orders to `market buy` orders instead of skipping them. |
| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. |
| `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional LongBridge-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
| `INCOME_THRESHOLD_USD` | No | Optional strategy override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. |
| `QQQI_INCOME_RATIO` | No | Optional strategy 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) |
| `GOOGLE_CLOUD_PROJECT` | No | GCP project ID (defaults to ADC project when unset) |

Strategy allocation can still target fractional dollar values and fractional position weights. The LongBridge execution layer keeps the tested conservative rule: `limit buy` orders stay whole-share only by default, while `market buy` / `market sell` and `limit sell` can preserve fractional quantities when the target quantity is at least 1 share. If you set `LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET=true`, fractional `limit buy` orders are downgraded to market buys instead of being skipped. When a target value is zero, sell sizing uses the sellable position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift.
Strategy allocation can still target fractional dollar values and fractional position weights. The LongBridge execution layer now keeps a whole-share-only rule for every broker order: sell sizing floors to whole shares, buy sizing floors to whole shares, and fractional orders are skipped rather than downgraded. When a target value is zero, sell sizing uses the sellable position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift.

Secret Manager must contain the secret named by `LONGPORT_SECRET_NAME` (default: `longport_token_paper`), where the **latest version = active access token**. The app refreshes it when expiry is within 30 days.

Expand Down
76 changes: 5 additions & 71 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,51 +138,33 @@ def _floor_whole_share_quantity(quantity):
return normalize_order_quantity(floor_to_quantity_step(quantity, 1.0))


def _supports_fractional_quantity(*, order_kind: str, side: str) -> bool:
return not (order_kind == "limit" and side == "buy")


def _normalize_trade_quantity(quantity, *, allow_fractional: bool):
def _normalize_trade_quantity(quantity):
raw_quantity = max(0.0, float(quantity or 0.0))
if raw_quantity <= 0.0:
return 0
if allow_fractional:
if raw_quantity < 1.0:
return 0
return normalize_order_quantity(raw_quantity)
return _floor_whole_share_quantity(raw_quantity)


def _has_fractional_quantity(quantity) -> bool:
value = float(quantity or 0.0)
return value > 0.0 and not value.is_integer()


def _sell_order_quantity(
*,
current_value,
target_value,
price,
sellable_quantity,
allow_fractional: bool,
):
sellable = max(0.0, float(sellable_quantity or 0.0))
if sellable <= 0.0:
return 0

target = max(0.0, float(target_value or 0.0))
if target <= 0.0:
return _normalize_trade_quantity(
sellable,
allow_fractional=allow_fractional,
)
return _normalize_trade_quantity(sellable)

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 _normalize_trade_quantity(
min(sell_value / float(price), sellable),
allow_fractional=allow_fractional,
)


Expand Down Expand Up @@ -229,7 +211,7 @@ def _estimate_buy_quantity_candidate(
notify_issue,
dry_run_only=False,
):
budget_quantity = floor_to_quantity_step(can_buy_value / ref_price, 0.0001)
budget_quantity = floor_to_quantity_step(can_buy_value / ref_price, 1.0)
cash_limit_quantity = estimate_cash_buy_quantity_safe(
trade_context,
symbol,
Expand All @@ -240,11 +222,8 @@ def _estimate_buy_quantity_candidate(
)
if cash_limit_quantity is None:
return None
if dry_run_only and float(cash_limit_quantity or 0.0) <= 0.0:
cash_limit_quantity = budget_quantity
candidate_quantity = _normalize_trade_quantity(
min(budget_quantity, float(cash_limit_quantity)),
allow_fractional=True,
)
return candidate_quantity, budget_quantity, float(cash_limit_quantity)

Expand All @@ -267,7 +246,6 @@ def execute_rebalance_cycle(
limit_sell_discount,
limit_buy_premium,
dry_run_only=False,
fractional_limit_buy_fallback_to_market=False,
post_sell_refresh_attempts=1,
post_sell_refresh_interval_sec=0.0,
sleeper=_noop_sleep,
Expand Down Expand Up @@ -305,15 +283,10 @@ def append_order_id_suffix(log_message, order_id):
return f"{log_message} {suffix}"

def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, submitted_price=None):
allow_fractional = _supports_fractional_quantity(order_kind=order_type, side=side)
order_intent = OrderIntent(
symbol=symbol,
side=side,
quantity=(
_normalize_trade_quantity(quantity, allow_fractional=allow_fractional)
if allow_fractional
else _floor_whole_share_quantity(quantity)
),
quantity=_floor_whole_share_quantity(quantity),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize sweep sell quantity before funding checks

Flooring every submitted order quantity here introduces a mismatch with the cash-sweep path in execute_rebalance_cycle, which still computes available_sweep_cash, dry-run sale proceeds, and quantity text from the unfloored sellable_quantities value. With legacy fractional holdings (for example 3.75 shares), the strategy can conclude a sweep sale will fund a whole-share buy, but the actual submitted order is truncated to 3 shares, so the follow-on buy may be impossible (and dry-run can overstate executable cash). Please normalize sweep quantities to whole shares before funding/proceeds/log decisions so those decisions match what is actually submitted.

Useful? React with 👍 / 👎.

order_type=order_type,
limit_price=float(submitted_price) if submitted_price is not None else None,
)
Expand Down Expand Up @@ -394,10 +367,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
target_value=target_values[symbol],
price=price,
sellable_quantity=sellable_quantities[symbol],
allow_fractional=_supports_fractional_quantity(
order_kind="limit" if symbol in limit_order_symbols else "market",
side="sell",
),
)
if quantity > 0:
quantity_text = format_quantity(quantity)
Expand Down Expand Up @@ -629,45 +598,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
if limit_candidate is None:
continue
limit_candidate_quantity, limit_budget_quantity, limit_cash_limit_quantity = limit_candidate
if is_limit_order:
limit_quantity = _normalize_trade_quantity(
limit_candidate_quantity,
allow_fractional=False,
)
else:
limit_quantity = limit_candidate_quantity
limit_quantity = _normalize_trade_quantity(limit_candidate_quantity)
order_kind = limit_order_kind
ref_price = limit_ref_price
quantity = limit_quantity
if is_limit_order and fractional_limit_buy_fallback_to_market and _has_fractional_quantity(
limit_candidate_quantity
):
market_ref_price = round(price, 2)
market_candidate = _estimate_buy_quantity_candidate(
trade_context,
f"{symbol}.US",
"market",
market_ref_price,
can_buy_value=can_buy_value,
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
notify_issue=notify_issue,
dry_run_only=dry_run_only,
)
if market_candidate is not None:
market_quantity, _market_budget_quantity, _market_cash_limit_quantity = market_candidate
if market_quantity >= 1.0:
order_kind = "market"
ref_price = market_ref_price
quantity = market_quantity
fallback_message = translator(
"fractional_limit_buy_fallback_to_market",
symbol=symbol,
qty=format_quantity(quantity),
limit_price=limit_ref_price,
market_price=market_ref_price,
)
note_logs.append(fallback_message)
print(with_prefix(fallback_message), flush=True)
cost_estimate = 0.0
if quantity <= 0:
record_note_log(
Expand Down
1 change: 0 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ def fetch_replanned_state():
limit_sell_discount=config.limit_sell_discount,
limit_buy_premium=config.limit_buy_premium,
dry_run_only=config.dry_run_only,
fractional_limit_buy_fallback_to_market=config.fractional_limit_buy_fallback_to_market,
post_sell_refresh_attempts=config.post_sell_refresh_attempts,
post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec,
sleeper=config.sleeper or _noop_sleep,
Expand Down
4 changes: 0 additions & 4 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ class LongBridgeRuntimeComposer:
order_poll_interval_sec: int
order_poll_max_attempts: int
dry_run_only: bool = False
fractional_limit_buy_fallback_to_market: bool = False
broker_adapters: Any = None
strategy_adapters: Any = None
estimate_max_purchase_quantity_fn: Callable[..., float] | None = None
Expand Down Expand Up @@ -177,7 +176,6 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb
with_prefix=self.with_prefix,
strategy_display_name=self.strategy_display_name_localized,
dry_run_only=self.dry_run_only,
fractional_limit_buy_fallback_to_market=self.fractional_limit_buy_fallback_to_market,
post_sell_refresh_attempts=self.order_poll_max_attempts,
post_sell_refresh_interval_sec=self.order_poll_interval_sec,
sleeper=self.sleeper,
Expand Down Expand Up @@ -225,7 +223,6 @@ def build_runtime_composer(
order_poll_interval_sec: int,
order_poll_max_attempts: int,
dry_run_only: bool,
fractional_limit_buy_fallback_to_market: bool,
dry_run_only_override: bool | None = None,
broker_adapters: Any,
strategy_adapters: Any,
Expand Down Expand Up @@ -267,7 +264,6 @@ def build_runtime_composer(
order_poll_interval_sec=int(order_poll_interval_sec),
order_poll_max_attempts=int(order_poll_max_attempts),
dry_run_only=bool(dry_run_only if dry_run_only_override is None else dry_run_only_override),
fractional_limit_buy_fallback_to_market=bool(fractional_limit_buy_fallback_to_market),
broker_adapters=broker_adapters,
strategy_adapters=strategy_adapters,
estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity_fn,
Expand Down
1 change: 0 additions & 1 deletion application/runtime_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ class LongBridgeRebalanceConfig:
with_prefix: Callable[[str], str]
strategy_display_name: str = ""
dry_run_only: bool = False
fractional_limit_buy_fallback_to_market: bool = False
post_sell_refresh_attempts: int = 1
post_sell_refresh_interval_sec: float = 0.0
sleeper: Callable[[float], None] | None = None
Expand Down
1 change: 0 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ def build_composer(*, dry_run_only_override: bool | None = None):
order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS,
dry_run_only=RUNTIME_SETTINGS.dry_run_only,
dry_run_only_override=dry_run_only_override,
fractional_limit_buy_fallback_to_market=RUNTIME_SETTINGS.fractional_limit_buy_fallback_to_market,
broker_adapters=BROKER_ADAPTERS,
strategy_adapters=STRATEGY_ADAPTERS,
estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity,
Expand Down
2 changes: 0 additions & 2 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
"buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})",
"buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
"cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}",
"fractional_limit_buy_fallback_to_market": "{symbol} 限价买入碎股不稳定,已改为市价买入 {qty} 股;限价参考价 ${limit_price},市价参考价 ${market_price}",
"limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}",
"market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}",
"limit_sell": "📉 [限价卖出] {symbol}: {qty}股 @ ${price}",
Expand Down Expand Up @@ -157,7 +156,6 @@
"buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}",
"buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds",
"cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}",
"fractional_limit_buy_fallback_to_market": "{symbol} fractional limit buy is unstable, falling back to market buy for {qty} shares; limit reference ${limit_price}, market reference ${market_price}",
"limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price}",
"market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}",
"limit_sell": "📉 [Limit sell] {symbol}: {qty} shares @ ${price}",
Expand Down
4 changes: 0 additions & 4 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ class PlatformRuntimeSettings:
tg_token: str | None
tg_chat_id: str | None
dry_run_only: bool
fractional_limit_buy_fallback_to_market: bool = False
debug_position_snapshot: bool = False
income_threshold_usd: float | None = None
qqqi_income_ratio: float | None = None
Expand Down Expand Up @@ -113,9 +112,6 @@ def load_platform_runtime_settings(
tg_token=os.getenv("TELEGRAM_TOKEN"),
tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"),
dry_run_only=resolve_bool_value(os.getenv("LONGBRIDGE_DRY_RUN_ONLY")),
fractional_limit_buy_fallback_to_market=resolve_bool_value(
os.getenv("LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET")
),
debug_position_snapshot=resolve_bool_value(os.getenv("LONGBRIDGE_DEBUG_POSITION_SNAPSHOT")),
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
qqqi_income_ratio=_qqqi_income_ratio_env(),
Expand Down
Loading