Skip to content

Commit e6c6149

Browse files
committed
Add LongBridge order quantity probe tests
1 parent 9a4fec4 commit e6c6149

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

tests/test_longbridge_execution.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ def test_submit_order_rejects_fractional_quantity_before_api_call(self) -> None:
127127
self.assertIn("whole-share quantity of at least 1 share", report.raw_payload["detail"])
128128
self.assertFalse(hasattr(ctx, "submit_args"))
129129

130+
def test_submit_order_rejects_fractional_sell_before_api_call(self) -> None:
131+
longport_module = types.ModuleType("longport")
132+
openapi_module = types.ModuleType("longport.openapi")
133+
openapi_module.OrderSide = types.SimpleNamespace(Buy="Buy", Sell="Sell")
134+
openapi_module.OrderType = types.SimpleNamespace(LO="LO", MO="MO")
135+
openapi_module.TimeInForceType = types.SimpleNamespace(Day="Day")
136+
137+
ctx = FakeTradeContext()
138+
with patch.dict(sys.modules, {"longport": longport_module, "longport.openapi": openapi_module}):
139+
report = submit_order(ctx, "BOXX.US", order_kind="market", side="sell", quantity=4.6177)
140+
141+
self.assertEqual(report.status, "rejected")
142+
self.assertIn("whole-share quantity of at least 1 share", report.raw_payload["detail"])
143+
self.assertFalse(hasattr(ctx, "submit_args"))
144+
130145
def test_fetch_order_status(self) -> None:
131146
status = fetch_order_status(FakeTradeContext(), "OID-1")
132147

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import unittest
5+
from dataclasses import dataclass
6+
from decimal import Decimal, InvalidOperation
7+
from unittest.mock import patch
8+
from uuid import uuid4
9+
10+
11+
PROBE_ENV = "LONGBRIDGE_ORDER_API_PROBE"
12+
13+
14+
@dataclass(frozen=True)
15+
class ProbeCase:
16+
symbol: str
17+
side: str
18+
kind: str
19+
quantity: Decimal
20+
expect: str
21+
price: Decimal | None = None
22+
outside_rth: str | None = None
23+
error_contains: str | None = None
24+
25+
26+
def _required_env(name: str) -> str:
27+
value = os.getenv(name, "").strip()
28+
if not value:
29+
raise AssertionError(f"{name} is required when {PROBE_ENV}=1")
30+
return value
31+
32+
33+
def _split_csv(value: str) -> list[str]:
34+
return [part.strip() for part in value.split(",") if part.strip()]
35+
36+
37+
def _parse_decimal(value: str, *, field_name: str) -> Decimal:
38+
try:
39+
return Decimal(value)
40+
except InvalidOperation as exc:
41+
raise AssertionError(f"{field_name} must be a decimal value; got {value!r}") from exc
42+
43+
44+
def _parse_key_value_case(raw_case: str) -> ProbeCase:
45+
fields: dict[str, str] = {}
46+
for raw_part in raw_case.split(","):
47+
part = raw_part.strip()
48+
if not part:
49+
continue
50+
key, sep, value = part.partition("=")
51+
if not sep:
52+
raise AssertionError(f"Probe case part must be key=value; got {part!r}")
53+
fields[key.strip().lower()] = value.strip()
54+
55+
symbol = fields.get("symbol", "")
56+
side = fields.get("side", "").lower()
57+
kind = fields.get("kind", "").lower()
58+
expect = fields.get("expect", "").lower()
59+
quantity_text = fields.get("quantity", "")
60+
price_text = fields.get("price")
61+
62+
if not symbol:
63+
raise AssertionError(f"Probe case is missing symbol: {raw_case!r}")
64+
if side not in {"buy", "sell"}:
65+
raise AssertionError(f"Probe side must be buy or sell; got {side!r}")
66+
if kind not in {"limit", "market"}:
67+
raise AssertionError(f"Probe kind must be limit or market; got {kind!r}")
68+
if expect not in {"accepted", "rejected"}:
69+
raise AssertionError(f"Probe expect must be accepted or rejected; got {expect!r}")
70+
if not quantity_text:
71+
raise AssertionError(f"Probe case is missing quantity: {raw_case!r}")
72+
if kind == "limit" and not price_text:
73+
raise AssertionError(f"Limit probe case requires price: {raw_case!r}")
74+
75+
return ProbeCase(
76+
symbol=symbol,
77+
side=side,
78+
kind=kind,
79+
quantity=_parse_decimal(quantity_text, field_name="quantity"),
80+
expect=expect,
81+
price=_parse_decimal(price_text, field_name="price") if price_text else None,
82+
outside_rth=fields.get("outside_rth"),
83+
error_contains=fields.get("error_contains"),
84+
)
85+
86+
87+
def _parse_probe_cases() -> list[ProbeCase]:
88+
raw_cases = os.getenv("LONGBRIDGE_ORDER_API_PROBE_CASES", "").strip()
89+
if raw_cases:
90+
return [_parse_key_value_case(raw_case) for raw_case in raw_cases.split(";") if raw_case.strip()]
91+
92+
symbols = _split_csv(_required_env("LONGBRIDGE_ORDER_API_PROBE_SYMBOLS"))
93+
side = _required_env("LONGBRIDGE_ORDER_API_PROBE_SIDE").lower()
94+
kind = _required_env("LONGBRIDGE_ORDER_API_PROBE_KIND").lower()
95+
expect = _required_env("LONGBRIDGE_ORDER_API_PROBE_EXPECT").lower()
96+
quantity = _parse_decimal(_required_env("LONGBRIDGE_ORDER_API_PROBE_QUANTITY"), field_name="quantity")
97+
price = os.getenv("LONGBRIDGE_ORDER_API_PROBE_LIMIT_PRICE", "").strip()
98+
outside_rth = os.getenv("LONGBRIDGE_ORDER_API_PROBE_OUTSIDE_RTH", "").strip() or None
99+
error_contains = os.getenv("LONGBRIDGE_ORDER_API_PROBE_ERROR_CONTAINS", "").strip() or None
100+
101+
if side not in {"buy", "sell"}:
102+
raise AssertionError(f"LONGBRIDGE_ORDER_API_PROBE_SIDE must be buy or sell; got {side!r}")
103+
if kind not in {"limit", "market"}:
104+
raise AssertionError(f"LONGBRIDGE_ORDER_API_PROBE_KIND must be limit or market; got {kind!r}")
105+
if expect not in {"accepted", "rejected"}:
106+
raise AssertionError(f"LONGBRIDGE_ORDER_API_PROBE_EXPECT must be accepted or rejected; got {expect!r}")
107+
if kind == "limit" and not price:
108+
raise AssertionError("LONGBRIDGE_ORDER_API_PROBE_LIMIT_PRICE is required for limit probes")
109+
110+
return [
111+
ProbeCase(
112+
symbol=symbol,
113+
side=side,
114+
kind=kind,
115+
quantity=quantity,
116+
expect=expect,
117+
price=_parse_decimal(price, field_name="limit_price") if price else None,
118+
outside_rth=outside_rth,
119+
error_contains=error_contains,
120+
)
121+
for symbol in symbols
122+
]
123+
124+
125+
def _order_side(openapi_module, side: str):
126+
if side == "buy":
127+
return openapi_module.OrderSide.Buy
128+
if side == "sell":
129+
return openapi_module.OrderSide.Sell
130+
raise AssertionError(f"Unsupported side: {side}")
131+
132+
133+
def _order_type(openapi_module, kind: str):
134+
if kind == "limit":
135+
return openapi_module.OrderType.LO
136+
if kind == "market":
137+
return openapi_module.OrderType.MO
138+
raise AssertionError(f"Unsupported order kind: {kind}")
139+
140+
141+
def _outside_rth(openapi_module, value: str | None):
142+
normalized = (value or os.getenv("LONGBRIDGE_ORDER_API_PROBE_OUTSIDE_RTH", "anytime")).strip().lower()
143+
if normalized in {"", "none"}:
144+
return None
145+
if normalized in {"anytime", "any_time", "any-time"}:
146+
return openapi_module.OutsideRTH.AnyTime
147+
if normalized in {"rth", "rth_only", "rth-only"}:
148+
return openapi_module.OutsideRTH.RTHOnly
149+
if normalized == "overnight":
150+
return openapi_module.OutsideRTH.Overnight
151+
raise AssertionError(f"Unsupported outside_rth value: {value!r}")
152+
153+
154+
class LongBridgeOrderQuantityApiProbeConfigTests(unittest.TestCase):
155+
def test_parse_key_value_cases_supports_multiple_symbols_and_prices(self) -> None:
156+
raw_cases = (
157+
"symbol=SOXL.US,side=buy,kind=limit,quantity=1.5,price=120,expect=rejected;"
158+
"symbol=BOXX.US,side=sell,kind=limit,quantity=4.6177,price=130,expect=accepted"
159+
)
160+
161+
with patch.dict(os.environ, {"LONGBRIDGE_ORDER_API_PROBE_CASES": raw_cases}, clear=True):
162+
cases = _parse_probe_cases()
163+
164+
self.assertEqual([case.symbol for case in cases], ["SOXL.US", "BOXX.US"])
165+
self.assertEqual([case.side for case in cases], ["buy", "sell"])
166+
self.assertEqual([case.kind for case in cases], ["limit", "limit"])
167+
self.assertEqual([case.quantity for case in cases], [Decimal("1.5"), Decimal("4.6177")])
168+
self.assertEqual([case.price for case in cases], [Decimal("120"), Decimal("130")])
169+
self.assertEqual([case.expect for case in cases], ["rejected", "accepted"])
170+
171+
def test_parse_symbol_list_expands_one_case_per_symbol(self) -> None:
172+
env = {
173+
"LONGBRIDGE_ORDER_API_PROBE_SYMBOLS": "SOXL.US, SOXX.US, BOXX.US",
174+
"LONGBRIDGE_ORDER_API_PROBE_SIDE": "buy",
175+
"LONGBRIDGE_ORDER_API_PROBE_KIND": "limit",
176+
"LONGBRIDGE_ORDER_API_PROBE_EXPECT": "rejected",
177+
"LONGBRIDGE_ORDER_API_PROBE_QUANTITY": "0.4326",
178+
"LONGBRIDGE_ORDER_API_PROBE_LIMIT_PRICE": "1",
179+
"LONGBRIDGE_ORDER_API_PROBE_OUTSIDE_RTH": "anytime",
180+
}
181+
182+
with patch.dict(os.environ, env, clear=True):
183+
cases = _parse_probe_cases()
184+
185+
self.assertEqual([case.symbol for case in cases], ["SOXL.US", "SOXX.US", "BOXX.US"])
186+
self.assertTrue(all(case.quantity == Decimal("0.4326") for case in cases))
187+
self.assertTrue(all(case.price == Decimal("1") for case in cases))
188+
self.assertTrue(all(case.outside_rth == "anytime" for case in cases))
189+
190+
def test_limit_symbol_list_requires_price(self) -> None:
191+
env = {
192+
"LONGBRIDGE_ORDER_API_PROBE_SYMBOLS": "SOXL.US",
193+
"LONGBRIDGE_ORDER_API_PROBE_SIDE": "buy",
194+
"LONGBRIDGE_ORDER_API_PROBE_KIND": "limit",
195+
"LONGBRIDGE_ORDER_API_PROBE_EXPECT": "rejected",
196+
"LONGBRIDGE_ORDER_API_PROBE_QUANTITY": "0.4326",
197+
}
198+
199+
with patch.dict(os.environ, env, clear=True):
200+
with self.assertRaisesRegex(AssertionError, "LIMIT_PRICE is required"):
201+
_parse_probe_cases()
202+
203+
204+
@unittest.skipUnless(os.getenv(PROBE_ENV) == "1", f"set {PROBE_ENV}=1 to run LongBridge API order probes")
205+
class LongBridgeOrderQuantityApiProbeTests(unittest.TestCase):
206+
def test_configured_order_quantity_cases(self) -> None:
207+
from longport import OpenApiException
208+
from longport.openapi import Config, TradeContext
209+
import longport.openapi as openapi
210+
211+
app_key = _required_env("LONGPORT_APP_KEY")
212+
app_secret = _required_env("LONGPORT_APP_SECRET")
213+
access_token = _required_env("LONGPORT_ACCESS_TOKEN")
214+
cases = _parse_probe_cases()
215+
self.assertTrue(cases, "at least one LongBridge order probe case is required")
216+
217+
ctx = TradeContext(Config(app_key=app_key, app_secret=app_secret, access_token=access_token))
218+
for case in cases:
219+
with self.subTest(case=case):
220+
if case.kind == "market" and os.getenv("LONGBRIDGE_ORDER_API_PROBE_ALLOW_MARKET") != "1":
221+
self.fail("Market probes can fill immediately; set LONGBRIDGE_ORDER_API_PROBE_ALLOW_MARKET=1")
222+
223+
kwargs = {"remark": f"qpk-order-quantity-probe-{uuid4().hex[:8]}"}
224+
if case.kind == "limit":
225+
kwargs["submitted_price"] = case.price
226+
kwargs["outside_rth"] = _outside_rth(openapi, case.outside_rth)
227+
228+
try:
229+
response = ctx.submit_order(
230+
case.symbol,
231+
_order_type(openapi, case.kind),
232+
_order_side(openapi, case.side),
233+
case.quantity,
234+
openapi.TimeInForceType.Day,
235+
**kwargs,
236+
)
237+
except OpenApiException as exc:
238+
if case.expect == "rejected":
239+
if case.error_contains and case.error_contains not in str(exc):
240+
self.fail(
241+
f"LongBridge rejected {case}, but error did not contain "
242+
f"{case.error_contains!r}: {exc}"
243+
)
244+
continue
245+
self.fail(f"LongBridge rejected {case}, expected accepted: {exc}")
246+
247+
order_id = str(getattr(response, "order_id", "") or "")
248+
if case.expect == "rejected":
249+
if order_id:
250+
self._cancel_order(ctx, order_id, required=False)
251+
self.fail(f"LongBridge accepted {case}, expected rejected; order_id={order_id}")
252+
253+
self.assertTrue(order_id, f"LongBridge accepted {case} but returned no order_id")
254+
if case.kind == "limit" or os.getenv("LONGBRIDGE_ORDER_API_PROBE_CANCEL_MARKET") == "1":
255+
self._cancel_order(ctx, order_id, required=case.kind == "limit")
256+
257+
def _cancel_order(self, ctx, order_id: str, *, required: bool) -> None:
258+
try:
259+
ctx.cancel_order(order_id)
260+
except Exception as exc:
261+
if required:
262+
raise AssertionError(f"failed to cancel accepted probe order {order_id}: {exc}") from exc
263+
264+
265+
if __name__ == "__main__":
266+
unittest.main()

0 commit comments

Comments
 (0)