From b0fe06256963fd9012e418f8d6e4a043dce45d1c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 8 May 2026 03:53:04 +0800 Subject: [PATCH] Handle LongBridge quote limits and sub-share orders --- src/quant_platform_kit/longbridge/__init__.py | 3 +- .../longbridge/execution.py | 17 ++++- .../longbridge/market_data.py | 62 +++++++++++++++++-- .../longbridge/portfolio.py | 27 +++++--- tests/test_longbridge_execution.py | 22 +++++++ tests/test_longbridge_market_data.py | 35 ++++++++++- tests/test_longbridge_portfolio.py | 13 +++- 7 files changed, 158 insertions(+), 21 deletions(-) diff --git a/src/quant_platform_kit/longbridge/__init__.py b/src/quant_platform_kit/longbridge/__init__.py index ebdd316..1ef578d 100644 --- a/src/quant_platform_kit/longbridge/__init__.py +++ b/src/quant_platform_kit/longbridge/__init__.py @@ -1,6 +1,6 @@ from .auth import build_contexts, fetch_token_from_secret, refresh_token_if_needed from .execution import estimate_max_purchase_quantity, fetch_order_status, submit_order -from .market_data import calculate_rotation_indicators, fetch_last_price +from .market_data import calculate_rotation_indicators, fetch_last_price, fetch_last_prices from .portfolio import fetch_strategy_account_state __all__ = [ @@ -12,5 +12,6 @@ "submit_order", "calculate_rotation_indicators", "fetch_last_price", + "fetch_last_prices", "fetch_strategy_account_state", ] diff --git a/src/quant_platform_kit/longbridge/execution.py b/src/quant_platform_kit/longbridge/execution.py index f3de3f8..d9d543b 100644 --- a/src/quant_platform_kit/longbridge/execution.py +++ b/src/quant_platform_kit/longbridge/execution.py @@ -39,6 +39,21 @@ def submit_order( order_type = OrderType.LO if order_kind == "limit" else OrderType.MO order_side = OrderSide.Buy if side == "buy" else OrderSide.Sell + submitted_quantity = Decimal(str(quantity)) + if submitted_quantity < Decimal("1"): + return ExecutionReport( + symbol=symbol.split(".")[0], + side=side, + quantity=float(quantity), + status="rejected", + raw_payload={ + "detail": ( + "LongBridge submitted_quantity must be at least 1 share; " + f"got {submitted_quantity}." + ), + "order_kind": order_kind, + }, + ) kwargs: dict[str, Any] = {} if submitted_price is not None: @@ -48,7 +63,7 @@ def submit_order( symbol, order_type, order_side, - Decimal(str(quantity)), + submitted_quantity, TimeInForceType.Day, **kwargs, ) diff --git a/src/quant_platform_kit/longbridge/market_data.py b/src/quant_platform_kit/longbridge/market_data.py index 30f8462..12f7c34 100644 --- a/src/quant_platform_kit/longbridge/market_data.py +++ b/src/quant_platform_kit/longbridge/market_data.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from typing import Any import pandas as pd @@ -9,11 +10,64 @@ ) +def _normalize_symbol(symbol: str) -> str: + return str(symbol or "").strip().upper() + + +def _is_rate_limit_exception(exc: Exception) -> bool: + code = getattr(exc, "code", None) + if str(code) == "301606": + return True + message = str(exc).lower() + return "301606" in message or "request rate limit" in message + + +def _quote_with_retry( + q_ctx: Any, + symbols: list[str], + *, + max_attempts: int = 3, + initial_delay_sec: float = 1.0, +) -> list[Any]: + for attempt in range(max(1, max_attempts)): + try: + return list(q_ctx.quote(symbols) or []) + except Exception as exc: + if attempt >= max_attempts - 1 or not _is_rate_limit_exception(exc): + raise + time.sleep(initial_delay_sec * (2**attempt)) + return [] + + def fetch_last_price(q_ctx: Any, symbol: str) -> float | None: - quotes = q_ctx.quote([symbol]) - if not quotes: - return None - return float(quotes[0].last_done) + return fetch_last_prices(q_ctx, [symbol]).get(_normalize_symbol(symbol)) + + +def fetch_last_prices(q_ctx: Any, symbols: list[str] | tuple[str, ...]) -> dict[str, float]: + normalized_symbols = [] + for symbol in symbols: + normalized_symbol = _normalize_symbol(symbol) + if normalized_symbol: + normalized_symbols.append(normalized_symbol) + normalized_symbols = list(dict.fromkeys(normalized_symbols)) + if not normalized_symbols: + return {} + + quotes = _quote_with_retry(q_ctx, normalized_symbols) + prices: dict[str, float] = {} + for index, quote in enumerate(quotes): + fallback_symbol = normalized_symbols[index] if index < len(normalized_symbols) else "" + quoted_symbol = _normalize_symbol(getattr(quote, "symbol", "") or fallback_symbol) + if not quoted_symbol: + continue + last_done = getattr(quote, "last_done", None) + if last_done is None: + continue + try: + prices[quoted_symbol] = float(last_done) + except (TypeError, ValueError): + continue + return prices def calculate_rotation_indicators( diff --git a/src/quant_platform_kit/longbridge/portfolio.py b/src/quant_platform_kit/longbridge/portfolio.py index 68f2038..07aac49 100644 --- a/src/quant_platform_kit/longbridge/portfolio.py +++ b/src/quant_platform_kit/longbridge/portfolio.py @@ -2,7 +2,7 @@ from typing import Any, Callable, Iterable -from .market_data import fetch_last_price +from .market_data import fetch_last_prices def fetch_strategy_account_state( @@ -31,11 +31,14 @@ def fetch_strategy_account_state( sellable_quantities = {symbol: 0.0 for symbol in assets} filter_enabled = bool(assets) + position_rows: list[tuple[str, str, Any, Any]] = [] positions_response = t_ctx.stock_positions() if positions_response and hasattr(positions_response, "channels"): for channel in positions_response.channels: for position in getattr(channel, "positions", []): - full_symbol = getattr(position, "symbol", "") + full_symbol = str(getattr(position, "symbol", "") or "").strip().upper() + if not full_symbol: + continue root_symbol = full_symbol.split(".")[0].strip().upper() if filter_enabled and root_symbol not in market_values: continue @@ -57,15 +60,19 @@ def fetch_strategy_account_state( f"quantity={raw_quantity} available_quantity={raw_available_quantity}" ) - last_price = fetch_last_price(q_ctx, full_symbol) - if last_price is None: - continue + position_rows.append((root_symbol, full_symbol, raw_quantity, raw_available_quantity)) + + prices = fetch_last_prices(q_ctx, [full_symbol for _root_symbol, full_symbol, _quantity, _available in position_rows]) + for root_symbol, full_symbol, raw_quantity, raw_available_quantity in position_rows: + last_price = prices.get(full_symbol) + if last_price is None: + continue - quantity = float(raw_quantity) - available_quantity = float(raw_available_quantity) - market_values[root_symbol] += quantity * last_price - quantities[root_symbol] += quantity - sellable_quantities[root_symbol] += available_quantity + quantity = float(raw_quantity) + available_quantity = float(raw_available_quantity) + market_values[root_symbol] += quantity * last_price + quantities[root_symbol] += quantity + sellable_quantities[root_symbol] += available_quantity if position_log_fn is not None: for symbol in assets or tuple(sorted(quantities)): diff --git a/tests/test_longbridge_execution.py b/tests/test_longbridge_execution.py index 4038659..14c1e99 100644 --- a/tests/test_longbridge_execution.py +++ b/tests/test_longbridge_execution.py @@ -68,6 +68,28 @@ def test_submit_order(self) -> None: self.assertEqual(report.status, "submitted") self.assertEqual(report.broker_order_id, "OID-1") + def test_submit_order_rejects_quantity_below_one_before_api_call(self) -> None: + longport_module = types.ModuleType("longport") + openapi_module = types.ModuleType("longport.openapi") + openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy", Sell="Sell") + openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO") + openapi_module.TimeInForceType = types.SimpleNamespace(Day="Day") + + ctx = FakeTradeContext() + with patch.dict(sys.modules, {"longport": longport_module, "longport.openapi": openapi_module}): + report = submit_order( + ctx, + "SOXX.US", + order_kind="limit", + side="buy", + quantity=0.4326, + submitted_price=495.91, + ) + + self.assertEqual(report.status, "rejected") + self.assertIn("at least 1 share", report.raw_payload["detail"]) + self.assertFalse(hasattr(ctx, "submit_args")) + def test_fetch_order_status(self) -> None: status = fetch_order_status(FakeTradeContext(), "OID-1") diff --git a/tests/test_longbridge_market_data.py b/tests/test_longbridge_market_data.py index b5fd002..6f5afb8 100644 --- a/tests/test_longbridge_market_data.py +++ b/tests/test_longbridge_market_data.py @@ -5,11 +5,12 @@ import unittest from unittest.mock import patch -from quant_platform_kit.longbridge.market_data import calculate_rotation_indicators, fetch_last_price +from quant_platform_kit.longbridge.market_data import calculate_rotation_indicators, fetch_last_price, fetch_last_prices class FakeQuote: - def __init__(self, last_done): + def __init__(self, symbol, last_done): + self.symbol = symbol self.last_done = last_done @@ -20,7 +21,8 @@ def __init__(self, close): class FakeQuoteContext: def quote(self, symbols): - return [FakeQuote(123.45)] + prices = {"SOXL.US": 123.45, "SOXX.US": 234.56} + return [FakeQuote(symbol, prices[symbol]) for symbol in symbols] def candlesticks(self, symbol, period, count, adjust_type): if symbol == "SOXL.US": @@ -32,6 +34,33 @@ class LongBridgeMarketDataTests(unittest.TestCase): def test_fetch_last_price(self) -> None: self.assertEqual(fetch_last_price(FakeQuoteContext(), "SOXL.US"), 123.45) + def test_fetch_last_prices_batches_symbols(self) -> None: + self.assertEqual( + fetch_last_prices(FakeQuoteContext(), ["SOXL.US", "SOXX.US", "SOXL.US"]), + {"SOXL.US": 123.45, "SOXX.US": 234.56}, + ) + + def test_fetch_last_price_retries_rate_limit(self) -> None: + class RateLimitError(Exception): + code = 301606 + + class RateLimitedQuoteContext(FakeQuoteContext): + def __init__(self): + self.calls = 0 + + def quote(self, symbols): + self.calls += 1 + if self.calls == 1: + raise RateLimitError("request rate limit") + return super().quote(symbols) + + quote_context = RateLimitedQuoteContext() + with patch("quant_platform_kit.longbridge.market_data.time.sleep") as sleep_mock: + self.assertEqual(fetch_last_price(quote_context, "SOXL.US"), 123.45) + + self.assertEqual(quote_context.calls, 2) + sleep_mock.assert_called_once_with(1.0) + def test_calculate_rotation_indicators(self) -> None: longport_module = types.ModuleType("longport") openapi_module = types.ModuleType("longport.openapi") diff --git a/tests/test_longbridge_portfolio.py b/tests/test_longbridge_portfolio.py index 1989735..ab142f2 100644 --- a/tests/test_longbridge_portfolio.py +++ b/tests/test_longbridge_portfolio.py @@ -37,9 +37,16 @@ def __init__(self): class FakeQuoteContext: + def __init__(self): + self.quote_calls = [] + def quote(self, symbols): + self.quote_calls.append(tuple(symbols)) prices = {"SOXL.US": 50.0, "QQQI.US": 20.0} - return [type("Quote", (), {"last_done": prices[symbols[0]]})()] + return [ + type("Quote", (), {"symbol": symbol, "last_done": prices[symbol]})() + for symbol in symbols + ] class FakeTradeContext: @@ -52,8 +59,9 @@ def stock_positions(self): class LongBridgePortfolioTests(unittest.TestCase): def test_fetch_strategy_account_state(self) -> None: + quote_context = FakeQuoteContext() state = fetch_strategy_account_state( - FakeQuoteContext(), + quote_context, FakeTradeContext(), ["SOXL", "QQQI", "SPYI"], ) @@ -64,6 +72,7 @@ def test_fetch_strategy_account_state(self) -> None: self.assertEqual(state["quantities"]["QQQI"], 2) self.assertEqual(state["sellable_quantities"]["QQQI"], 1) self.assertEqual(state["total_strategy_equity"], 1190.0) + self.assertEqual(quote_context.quote_calls, [("SOXL.US", "QQQI.US")]) def test_fetch_strategy_account_state_includes_all_positions_when_assets_empty(self) -> None: state = fetch_strategy_account_state(