Skip to content

Commit b0fe062

Browse files
committed
Handle LongBridge quote limits and sub-share orders
1 parent 3368b26 commit b0fe062

7 files changed

Lines changed: 158 additions & 21 deletions

File tree

src/quant_platform_kit/longbridge/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .auth import build_contexts, fetch_token_from_secret, refresh_token_if_needed
22
from .execution import estimate_max_purchase_quantity, fetch_order_status, submit_order
3-
from .market_data import calculate_rotation_indicators, fetch_last_price
3+
from .market_data import calculate_rotation_indicators, fetch_last_price, fetch_last_prices
44
from .portfolio import fetch_strategy_account_state
55

66
__all__ = [
@@ -12,5 +12,6 @@
1212
"submit_order",
1313
"calculate_rotation_indicators",
1414
"fetch_last_price",
15+
"fetch_last_prices",
1516
"fetch_strategy_account_state",
1617
]

src/quant_platform_kit/longbridge/execution.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ def submit_order(
3939

4040
order_type = OrderType.LO if order_kind == "limit" else OrderType.MO
4141
order_side = OrderSide.Buy if side == "buy" else OrderSide.Sell
42+
submitted_quantity = Decimal(str(quantity))
43+
if submitted_quantity < Decimal("1"):
44+
return ExecutionReport(
45+
symbol=symbol.split(".")[0],
46+
side=side,
47+
quantity=float(quantity),
48+
status="rejected",
49+
raw_payload={
50+
"detail": (
51+
"LongBridge submitted_quantity must be at least 1 share; "
52+
f"got {submitted_quantity}."
53+
),
54+
"order_kind": order_kind,
55+
},
56+
)
4257

4358
kwargs: dict[str, Any] = {}
4459
if submitted_price is not None:
@@ -48,7 +63,7 @@ def submit_order(
4863
symbol,
4964
order_type,
5065
order_side,
51-
Decimal(str(quantity)),
66+
submitted_quantity,
5267
TimeInForceType.Day,
5368
**kwargs,
5469
)

src/quant_platform_kit/longbridge/market_data.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import time
34
from typing import Any
45

56
import pandas as pd
@@ -9,11 +10,64 @@
910
)
1011

1112

13+
def _normalize_symbol(symbol: str) -> str:
14+
return str(symbol or "").strip().upper()
15+
16+
17+
def _is_rate_limit_exception(exc: Exception) -> bool:
18+
code = getattr(exc, "code", None)
19+
if str(code) == "301606":
20+
return True
21+
message = str(exc).lower()
22+
return "301606" in message or "request rate limit" in message
23+
24+
25+
def _quote_with_retry(
26+
q_ctx: Any,
27+
symbols: list[str],
28+
*,
29+
max_attempts: int = 3,
30+
initial_delay_sec: float = 1.0,
31+
) -> list[Any]:
32+
for attempt in range(max(1, max_attempts)):
33+
try:
34+
return list(q_ctx.quote(symbols) or [])
35+
except Exception as exc:
36+
if attempt >= max_attempts - 1 or not _is_rate_limit_exception(exc):
37+
raise
38+
time.sleep(initial_delay_sec * (2**attempt))
39+
return []
40+
41+
1242
def fetch_last_price(q_ctx: Any, symbol: str) -> float | None:
13-
quotes = q_ctx.quote([symbol])
14-
if not quotes:
15-
return None
16-
return float(quotes[0].last_done)
43+
return fetch_last_prices(q_ctx, [symbol]).get(_normalize_symbol(symbol))
44+
45+
46+
def fetch_last_prices(q_ctx: Any, symbols: list[str] | tuple[str, ...]) -> dict[str, float]:
47+
normalized_symbols = []
48+
for symbol in symbols:
49+
normalized_symbol = _normalize_symbol(symbol)
50+
if normalized_symbol:
51+
normalized_symbols.append(normalized_symbol)
52+
normalized_symbols = list(dict.fromkeys(normalized_symbols))
53+
if not normalized_symbols:
54+
return {}
55+
56+
quotes = _quote_with_retry(q_ctx, normalized_symbols)
57+
prices: dict[str, float] = {}
58+
for index, quote in enumerate(quotes):
59+
fallback_symbol = normalized_symbols[index] if index < len(normalized_symbols) else ""
60+
quoted_symbol = _normalize_symbol(getattr(quote, "symbol", "") or fallback_symbol)
61+
if not quoted_symbol:
62+
continue
63+
last_done = getattr(quote, "last_done", None)
64+
if last_done is None:
65+
continue
66+
try:
67+
prices[quoted_symbol] = float(last_done)
68+
except (TypeError, ValueError):
69+
continue
70+
return prices
1771

1872

1973
def calculate_rotation_indicators(

src/quant_platform_kit/longbridge/portfolio.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Callable, Iterable
44

5-
from .market_data import fetch_last_price
5+
from .market_data import fetch_last_prices
66

77

88
def fetch_strategy_account_state(
@@ -31,11 +31,14 @@ def fetch_strategy_account_state(
3131
sellable_quantities = {symbol: 0.0 for symbol in assets}
3232
filter_enabled = bool(assets)
3333

34+
position_rows: list[tuple[str, str, Any, Any]] = []
3435
positions_response = t_ctx.stock_positions()
3536
if positions_response and hasattr(positions_response, "channels"):
3637
for channel in positions_response.channels:
3738
for position in getattr(channel, "positions", []):
38-
full_symbol = getattr(position, "symbol", "")
39+
full_symbol = str(getattr(position, "symbol", "") or "").strip().upper()
40+
if not full_symbol:
41+
continue
3942
root_symbol = full_symbol.split(".")[0].strip().upper()
4043
if filter_enabled and root_symbol not in market_values:
4144
continue
@@ -57,15 +60,19 @@ def fetch_strategy_account_state(
5760
f"quantity={raw_quantity} available_quantity={raw_available_quantity}"
5861
)
5962

60-
last_price = fetch_last_price(q_ctx, full_symbol)
61-
if last_price is None:
62-
continue
63+
position_rows.append((root_symbol, full_symbol, raw_quantity, raw_available_quantity))
64+
65+
prices = fetch_last_prices(q_ctx, [full_symbol for _root_symbol, full_symbol, _quantity, _available in position_rows])
66+
for root_symbol, full_symbol, raw_quantity, raw_available_quantity in position_rows:
67+
last_price = prices.get(full_symbol)
68+
if last_price is None:
69+
continue
6370

64-
quantity = float(raw_quantity)
65-
available_quantity = float(raw_available_quantity)
66-
market_values[root_symbol] += quantity * last_price
67-
quantities[root_symbol] += quantity
68-
sellable_quantities[root_symbol] += available_quantity
71+
quantity = float(raw_quantity)
72+
available_quantity = float(raw_available_quantity)
73+
market_values[root_symbol] += quantity * last_price
74+
quantities[root_symbol] += quantity
75+
sellable_quantities[root_symbol] += available_quantity
6976

7077
if position_log_fn is not None:
7178
for symbol in assets or tuple(sorted(quantities)):

tests/test_longbridge_execution.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ def test_submit_order(self) -> None:
6868
self.assertEqual(report.status, "submitted")
6969
self.assertEqual(report.broker_order_id, "OID-1")
7070

71+
def test_submit_order_rejects_quantity_below_one_before_api_call(self) -> None:
72+
longport_module = types.ModuleType("longport")
73+
openapi_module = types.ModuleType("longport.openapi")
74+
openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy", Sell="Sell")
75+
openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO")
76+
openapi_module.TimeInForceType = types.SimpleNamespace(Day="Day")
77+
78+
ctx = FakeTradeContext()
79+
with patch.dict(sys.modules, {"longport": longport_module, "longport.openapi": openapi_module}):
80+
report = submit_order(
81+
ctx,
82+
"SOXX.US",
83+
order_kind="limit",
84+
side="buy",
85+
quantity=0.4326,
86+
submitted_price=495.91,
87+
)
88+
89+
self.assertEqual(report.status, "rejected")
90+
self.assertIn("at least 1 share", report.raw_payload["detail"])
91+
self.assertFalse(hasattr(ctx, "submit_args"))
92+
7193
def test_fetch_order_status(self) -> None:
7294
status = fetch_order_status(FakeTradeContext(), "OID-1")
7395

tests/test_longbridge_market_data.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
import unittest
66
from unittest.mock import patch
77

8-
from quant_platform_kit.longbridge.market_data import calculate_rotation_indicators, fetch_last_price
8+
from quant_platform_kit.longbridge.market_data import calculate_rotation_indicators, fetch_last_price, fetch_last_prices
99

1010

1111
class FakeQuote:
12-
def __init__(self, last_done):
12+
def __init__(self, symbol, last_done):
13+
self.symbol = symbol
1314
self.last_done = last_done
1415

1516

@@ -20,7 +21,8 @@ def __init__(self, close):
2021

2122
class FakeQuoteContext:
2223
def quote(self, symbols):
23-
return [FakeQuote(123.45)]
24+
prices = {"SOXL.US": 123.45, "SOXX.US": 234.56}
25+
return [FakeQuote(symbol, prices[symbol]) for symbol in symbols]
2426

2527
def candlesticks(self, symbol, period, count, adjust_type):
2628
if symbol == "SOXL.US":
@@ -32,6 +34,33 @@ class LongBridgeMarketDataTests(unittest.TestCase):
3234
def test_fetch_last_price(self) -> None:
3335
self.assertEqual(fetch_last_price(FakeQuoteContext(), "SOXL.US"), 123.45)
3436

37+
def test_fetch_last_prices_batches_symbols(self) -> None:
38+
self.assertEqual(
39+
fetch_last_prices(FakeQuoteContext(), ["SOXL.US", "SOXX.US", "SOXL.US"]),
40+
{"SOXL.US": 123.45, "SOXX.US": 234.56},
41+
)
42+
43+
def test_fetch_last_price_retries_rate_limit(self) -> None:
44+
class RateLimitError(Exception):
45+
code = 301606
46+
47+
class RateLimitedQuoteContext(FakeQuoteContext):
48+
def __init__(self):
49+
self.calls = 0
50+
51+
def quote(self, symbols):
52+
self.calls += 1
53+
if self.calls == 1:
54+
raise RateLimitError("request rate limit")
55+
return super().quote(symbols)
56+
57+
quote_context = RateLimitedQuoteContext()
58+
with patch("quant_platform_kit.longbridge.market_data.time.sleep") as sleep_mock:
59+
self.assertEqual(fetch_last_price(quote_context, "SOXL.US"), 123.45)
60+
61+
self.assertEqual(quote_context.calls, 2)
62+
sleep_mock.assert_called_once_with(1.0)
63+
3564
def test_calculate_rotation_indicators(self) -> None:
3665
longport_module = types.ModuleType("longport")
3766
openapi_module = types.ModuleType("longport.openapi")

tests/test_longbridge_portfolio.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,16 @@ def __init__(self):
3737

3838

3939
class FakeQuoteContext:
40+
def __init__(self):
41+
self.quote_calls = []
42+
4043
def quote(self, symbols):
44+
self.quote_calls.append(tuple(symbols))
4145
prices = {"SOXL.US": 50.0, "QQQI.US": 20.0}
42-
return [type("Quote", (), {"last_done": prices[symbols[0]]})()]
46+
return [
47+
type("Quote", (), {"symbol": symbol, "last_done": prices[symbol]})()
48+
for symbol in symbols
49+
]
4350

4451

4552
class FakeTradeContext:
@@ -52,8 +59,9 @@ def stock_positions(self):
5259

5360
class LongBridgePortfolioTests(unittest.TestCase):
5461
def test_fetch_strategy_account_state(self) -> None:
62+
quote_context = FakeQuoteContext()
5563
state = fetch_strategy_account_state(
56-
FakeQuoteContext(),
64+
quote_context,
5765
FakeTradeContext(),
5866
["SOXL", "QQQI", "SPYI"],
5967
)
@@ -64,6 +72,7 @@ def test_fetch_strategy_account_state(self) -> None:
6472
self.assertEqual(state["quantities"]["QQQI"], 2)
6573
self.assertEqual(state["sellable_quantities"]["QQQI"], 1)
6674
self.assertEqual(state["total_strategy_equity"], 1190.0)
75+
self.assertEqual(quote_context.quote_calls, [("SOXL.US", "QQQI.US")])
6776

6877
def test_fetch_strategy_account_state_includes_all_positions_when_assets_empty(self) -> None:
6978
state = fetch_strategy_account_state(

0 commit comments

Comments
 (0)