Skip to content

Commit ff4b92a

Browse files
committed
Add fractional limit buy fallback
1 parent 442cf59 commit ff4b92a

19 files changed

Lines changed: 397 additions & 31 deletions

.github/workflows/sync-cloud-run-env.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ jobs:
3737
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
3838
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
3939
LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}
40+
LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET: ${{ vars.LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET }}
4041
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
4142
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
4243
steps:
@@ -280,6 +281,11 @@ jobs:
280281
else
281282
remove_env_vars+=("LONGBRIDGE_DRY_RUN_ONLY")
282283
fi
284+
if [ -n "${LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET:-}" ]; then
285+
env_pairs+=("LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET=${LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET}")
286+
else
287+
remove_env_vars+=("LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET")
288+
fi
283289
284290
if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then
285291
env_pairs+=("INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}")
@@ -337,6 +343,7 @@ jobs:
337343
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
338344
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
339345
LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}
346+
LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET: ${{ vars.LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET }}
340347
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
341348
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
342349
steps:
@@ -580,6 +587,11 @@ jobs:
580587
else
581588
remove_env_vars+=("LONGBRIDGE_DRY_RUN_ONLY")
582589
fi
590+
if [ -n "${LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET:-}" ]; then
591+
env_pairs+=("LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET=${LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET}")
592+
else
593+
remove_env_vars+=("LONGBRIDGE_FRACTIONAL_LIMIT_BUY_FALLBACK_TO_MARKET")
594+
fi
583595
584596
if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then
585597
env_pairs+=("INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}")

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,14 @@ Telegram notifications include structured execution and heartbeat messages, with
6666
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. 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` |
6767
| `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) |
6868
| `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. |
69+
| `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. |
6970
| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. |
7071
| `INCOME_THRESHOLD_USD` | No | Optional override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. |
7172
| `QQQI_INCOME_RATIO` | No | Optional override for QQQI's share of the `tqqq_growth_income` income layer, 0–1. |
7273
| `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) |
7374
| `GOOGLE_CLOUD_PROJECT` | No | GCP project ID (defaults to ADC project when unset) |
7475

75-
Strategy allocation can still target fractional dollar values and fractional position weights. The LongBridge execution layer submits whole-share orders only because OpenAPI `submit_order` rejects fractional submitted quantities on the tested accounts. When a target value is zero, sell sizing uses the sellable whole-share position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift.
76+
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.
7677

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

application/execution_service.py

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -76,26 +76,51 @@ def _floor_whole_share_quantity(quantity):
7676
return normalize_order_quantity(floor_to_quantity_step(quantity, 1.0))
7777

7878

79+
def _supports_fractional_quantity(*, order_kind: str, side: str) -> bool:
80+
return not (order_kind == "limit" and side == "buy")
81+
82+
83+
def _normalize_trade_quantity(quantity, *, allow_fractional: bool):
84+
raw_quantity = max(0.0, float(quantity or 0.0))
85+
if raw_quantity <= 0.0:
86+
return 0
87+
if allow_fractional:
88+
if raw_quantity < 1.0:
89+
return 0
90+
return normalize_order_quantity(raw_quantity)
91+
return _floor_whole_share_quantity(raw_quantity)
92+
93+
94+
def _has_fractional_quantity(quantity) -> bool:
95+
value = float(quantity or 0.0)
96+
return value > 0.0 and not value.is_integer()
97+
98+
7999
def _sell_order_quantity(
80100
*,
81101
current_value,
82102
target_value,
83103
price,
84104
sellable_quantity,
105+
allow_fractional: bool,
85106
):
86107
sellable = max(0.0, float(sellable_quantity or 0.0))
87108
if sellable <= 0.0:
88109
return 0
89110

90111
target = max(0.0, float(target_value or 0.0))
91112
if target <= 0.0:
92-
return _floor_whole_share_quantity(sellable)
113+
return _normalize_trade_quantity(
114+
sellable,
115+
allow_fractional=allow_fractional,
116+
)
93117

94118
sell_value = max(0.0, float(current_value or 0.0) - target)
95119
if sell_value <= 0.0 or float(price or 0.0) <= 0.0:
96120
return 0
97-
return _floor_whole_share_quantity(
121+
return _normalize_trade_quantity(
98122
min(sell_value / float(price), sellable),
123+
allow_fractional=allow_fractional,
99124
)
100125

101126

@@ -131,6 +156,34 @@ def estimate_cash_buy_quantity_safe(
131156
return None
132157

133158

159+
def _estimate_buy_quantity_candidate(
160+
trade_context,
161+
symbol,
162+
order_kind,
163+
ref_price,
164+
*,
165+
can_buy_value,
166+
estimate_max_purchase_quantity,
167+
notify_issue,
168+
):
169+
budget_quantity = floor_to_quantity_step(can_buy_value / ref_price, 0.0001)
170+
cash_limit_quantity = estimate_cash_buy_quantity_safe(
171+
trade_context,
172+
symbol,
173+
order_kind,
174+
ref_price,
175+
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
176+
notify_issue=notify_issue,
177+
)
178+
if cash_limit_quantity is None:
179+
return None
180+
candidate_quantity = _normalize_trade_quantity(
181+
min(budget_quantity, float(cash_limit_quantity)),
182+
allow_fractional=True,
183+
)
184+
return candidate_quantity, budget_quantity, float(cash_limit_quantity)
185+
186+
134187
def execute_rebalance_cycle(
135188
*,
136189
trade_context,
@@ -149,6 +202,7 @@ def execute_rebalance_cycle(
149202
limit_sell_discount,
150203
limit_buy_premium,
151204
dry_run_only=False,
205+
fractional_limit_buy_fallback_to_market=False,
152206
post_sell_refresh_attempts=1,
153207
post_sell_refresh_interval_sec=0.0,
154208
sleeper=_noop_sleep,
@@ -183,14 +237,25 @@ def append_order_id_suffix(log_message, order_id):
183237
return f"{log_message} {suffix}"
184238

185239
def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, submitted_price=None):
240+
allow_fractional = _supports_fractional_quantity(order_kind=order_type, side=side)
186241
order_intent = OrderIntent(
187242
symbol=symbol,
188243
side=side,
189-
quantity=quantity,
244+
quantity=(
245+
_normalize_trade_quantity(quantity, allow_fractional=allow_fractional)
246+
if allow_fractional
247+
else _floor_whole_share_quantity(quantity)
248+
),
190249
order_type=order_type,
191250
limit_price=float(submitted_price) if submitted_price is not None else None,
192251
)
193252
side_text = "Buy" if side == "buy" else "Sell"
253+
if float(order_intent.quantity or 0.0) <= 0.0:
254+
notify_issue(
255+
"Order submit skipped",
256+
f"Symbol: {symbol} Side: {side_text} Qty: {quantity} Type: {order_type} Price: {submitted_price if submitted_price is not None else 'MO'}",
257+
)
258+
return False
194259
try:
195260
report = execution_port.submit_order(order_intent)
196261
except Exception:
@@ -261,6 +326,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
261326
target_value=target_values[symbol],
262327
price=price,
263328
sellable_quantity=sellable_quantities[symbol],
329+
allow_fractional=_supports_fractional_quantity(
330+
order_kind="limit" if symbol in limit_order_symbols else "market",
331+
side="sell",
332+
),
264333
)
265334
if quantity > 0:
266335
quantity_text = format_quantity(quantity)
@@ -386,28 +455,58 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
386455
can_buy_value = min(diff, investable_cash)
387456
if can_buy_value > price:
388457
is_limit_order = symbol in limit_order_symbols
389-
order_kind = "limit" if is_limit_order else "market"
390-
ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2)
391-
budget_price = ref_price if is_limit_order else price
392-
budget_quantity = floor_to_quantity_step(
393-
can_buy_value / budget_price,
394-
1.0,
395-
)
396-
cash_limit_quantity = estimate_cash_buy_quantity_safe(
458+
limit_order_kind = "limit" if is_limit_order else "market"
459+
limit_ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2)
460+
limit_candidate = _estimate_buy_quantity_candidate(
397461
trade_context,
398462
f"{symbol}.US",
399-
order_kind,
400-
ref_price,
463+
limit_order_kind,
464+
limit_ref_price,
465+
can_buy_value=can_buy_value,
401466
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
402467
notify_issue=notify_issue,
403468
)
404-
if cash_limit_quantity is None:
469+
if limit_candidate is None:
405470
continue
406-
effective_cash_limit_quantity = float(cash_limit_quantity)
407-
408-
quantity = _floor_whole_share_quantity(
409-
min(budget_quantity, effective_cash_limit_quantity),
410-
)
471+
limit_candidate_quantity, limit_budget_quantity, limit_cash_limit_quantity = limit_candidate
472+
if is_limit_order:
473+
limit_quantity = _normalize_trade_quantity(
474+
limit_candidate_quantity,
475+
allow_fractional=False,
476+
)
477+
else:
478+
limit_quantity = limit_candidate_quantity
479+
order_kind = limit_order_kind
480+
ref_price = limit_ref_price
481+
quantity = limit_quantity
482+
if is_limit_order and fractional_limit_buy_fallback_to_market and _has_fractional_quantity(
483+
limit_candidate_quantity
484+
):
485+
market_ref_price = round(price, 2)
486+
market_candidate = _estimate_buy_quantity_candidate(
487+
trade_context,
488+
f"{symbol}.US",
489+
"market",
490+
market_ref_price,
491+
can_buy_value=can_buy_value,
492+
estimate_max_purchase_quantity=estimate_max_purchase_quantity,
493+
notify_issue=notify_issue,
494+
)
495+
if market_candidate is not None:
496+
market_quantity, _market_budget_quantity, _market_cash_limit_quantity = market_candidate
497+
if market_quantity >= 1.0:
498+
order_kind = "market"
499+
ref_price = market_ref_price
500+
quantity = market_quantity
501+
fallback_message = translator(
502+
"fractional_limit_buy_fallback_to_market",
503+
symbol=symbol,
504+
qty=format_quantity(quantity),
505+
limit_price=limit_ref_price,
506+
market_price=market_ref_price,
507+
)
508+
note_logs.append(fallback_message)
509+
print(with_prefix(fallback_message), flush=True)
411510
cost_estimate = 0.0
412511
if quantity <= 0:
413512
record_note_log(
@@ -417,12 +516,12 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
417516
kind="buy_deferred_cash_limit",
418517
symbol=f"{symbol}.US",
419518
diff=f"{diff:.2f}",
420-
budget_qty=format_quantity(budget_quantity),
519+
budget_qty=format_quantity(limit_budget_quantity),
421520
)
422521
continue
423522

424523
quantity_text = format_quantity(quantity)
425-
if is_limit_order:
524+
if order_kind == "limit":
426525
if dry_run_only:
427526
submitted = record_dry_run(
428527
f"{symbol}.US",
@@ -440,7 +539,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
440539
translator("limit_buy", symbol=symbol, qty=quantity_text, price=ref_price),
441540
submitted_price=ref_price,
442541
)
443-
cost_estimate = quantity * budget_price
542+
cost_estimate = quantity * ref_price
444543
else:
445544
if dry_run_only:
446545
submitted = record_dry_run(
@@ -458,7 +557,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
458557
quantity,
459558
translator("market_buy", symbol=symbol, qty=quantity_text, price=round(price, 2)),
460559
)
461-
cost_estimate = quantity * budget_price
560+
cost_estimate = quantity * price
462561

463562
if submitted:
464563
investable_cash = max(0, investable_cash - cost_estimate)

application/rebalance_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def fetch_replanned_state():
211211
limit_sell_discount=config.limit_sell_discount,
212212
limit_buy_premium=config.limit_buy_premium,
213213
dry_run_only=config.dry_run_only,
214+
fractional_limit_buy_fallback_to_market=config.fractional_limit_buy_fallback_to_market,
214215
post_sell_refresh_attempts=config.post_sell_refresh_attempts,
215216
post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec,
216217
sleeper=config.sleeper or _noop_sleep,

application/runtime_composer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class LongBridgeRuntimeComposer:
3636
order_poll_interval_sec: int
3737
order_poll_max_attempts: int
3838
dry_run_only: bool = False
39+
fractional_limit_buy_fallback_to_market: bool = False
3940
broker_adapters: Any = None
4041
strategy_adapters: Any = None
4142
estimate_max_purchase_quantity_fn: Callable[..., float] | None = None
@@ -166,6 +167,7 @@ def build_rebalance_config(self) -> LongBridgeRebalanceConfig:
166167
with_prefix=self.with_prefix,
167168
strategy_display_name=self.strategy_display_name_localized,
168169
dry_run_only=self.dry_run_only,
170+
fractional_limit_buy_fallback_to_market=self.fractional_limit_buy_fallback_to_market,
169171
post_sell_refresh_attempts=self.order_poll_max_attempts,
170172
post_sell_refresh_interval_sec=self.order_poll_interval_sec,
171173
sleeper=self.sleeper,
@@ -195,6 +197,7 @@ def build_runtime_composer(
195197
order_poll_interval_sec: int,
196198
order_poll_max_attempts: int,
197199
dry_run_only: bool,
200+
fractional_limit_buy_fallback_to_market: bool,
198201
broker_adapters: Any,
199202
strategy_adapters: Any,
200203
estimate_max_purchase_quantity_fn: Callable[..., float],
@@ -234,6 +237,7 @@ def build_runtime_composer(
234237
order_poll_interval_sec=int(order_poll_interval_sec),
235238
order_poll_max_attempts=int(order_poll_max_attempts),
236239
dry_run_only=bool(dry_run_only),
240+
fractional_limit_buy_fallback_to_market=bool(fractional_limit_buy_fallback_to_market),
237241
broker_adapters=broker_adapters,
238242
strategy_adapters=strategy_adapters,
239243
estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity_fn,

application/runtime_dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class LongBridgeRebalanceConfig:
1818
with_prefix: Callable[[str], str]
1919
strategy_display_name: str = ""
2020
dry_run_only: bool = False
21+
fractional_limit_buy_fallback_to_market: bool = False
2122
post_sell_refresh_attempts: int = 1
2223
post_sell_refresh_interval_sec: float = 0.0
2324
sleeper: Callable[[float], None] | None = None

main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ def build_composer():
159159
order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC,
160160
order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS,
161161
dry_run_only=RUNTIME_SETTINGS.dry_run_only,
162+
fractional_limit_buy_fallback_to_market=RUNTIME_SETTINGS.fractional_limit_buy_fallback_to_market,
162163
broker_adapters=BROKER_ADAPTERS,
163164
strategy_adapters=STRATEGY_ADAPTERS,
164165
estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity,

notifications/telegram.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入",
6464
"buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})",
6565
"buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
66+
"fractional_limit_buy_fallback_to_market": "{symbol} 限价买入碎股不稳定,已改为市价买入 {qty} 股;限价参考价 ${limit_price},市价参考价 ${market_price}",
6667
"limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}",
6768
"market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}",
6869
"limit_sell": "📉 [限价卖出] {symbol}: {qty}股 @ ${price}",
@@ -143,6 +144,7 @@
143144
"buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying",
144145
"buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}",
145146
"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",
147+
"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}",
146148
"limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price}",
147149
"market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}",
148150
"limit_sell": "📉 [Limit sell] {symbol}: {qty} shares @ ${price}",

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@96f88e30fbb6bc504e8fe79d8248193d887cba28
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@af760b0b5b2fa6ca7817a1acadd10620c02afed8
3+
./vendor_wheels/quant_platform_kit-0.7.20-py3-none-any.whl
4+
./vendor_wheels/us_equity_strategies-0.7.35-py3-none-any.whl
55
pandas
66
requests
77
pytz

0 commit comments

Comments
 (0)