|
| 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