diff --git a/docs/api.md b/docs/api.md index 0110bae..32d9e83 100644 --- a/docs/api.md +++ b/docs/api.md @@ -323,6 +323,21 @@ passorder(opType, orderType, account_id, stockCode, prType, price, 响应直接序列化底层结果,不包装或重命名字段,具体结构由 QMT 提供。使用默认参数也需发送 `{}`。 +`get_ipo_data()` 实际返回**以证券代码为键的字典**(部分 QMT 版本再按市场分一层),键可用于 `passorder` 的 `stockCode`: + +```json +{"type":"STOCK"} +``` + +```json +{ + "301001.SZ":{"issuePrice":12.5,"maxPurchaseNum":15000,"stockName":"示例"}, + "601127.SH":{"issuePrice":4.09,"maxPurchaseNum":15000} +} +``` + +`py-client` 的 `sdk/trade.py` 会把它规范化成候选列表并补出 `stock` 字段;当日无新股时返回空字典。 + ## 10. Python 版本 `GET /api/sys/python_version`,无参数。 diff --git a/docs/ipo-submission-state.md b/docs/ipo-submission-state.md index aa57ad7..e68eeb1 100644 --- a/docs/ipo-submission-state.md +++ b/docs/ipo-submission-state.md @@ -16,4 +16,4 @@ 损坏或无法读取的记录不会触发重新提交;记录写入失败也不会提交。若长期查不到回报,需要先人工核对柜台结果,不能直接删除待确认记录后重跑。 -接口响应必须是列表,合法空列表表示无候选;非列表响应记录错误。候选要求完整代码及对应交易所、有限正价格、正整数数量。当前参与板块范围保持不变。 +接口响应是 QMT `get_ipo_data()` 的原样转发:以证券代码为键的字典(部分版本再按市场分一层),SDK 在 `sdk/trade.py` 规范化为候选列表并补全 `stock` 全码;空响应表示当日无候选,无法识别的结构抛错并记录,不静默当作"今日无新股"。候选要求完整代码及对应交易所、有限正价格、正整数数量。当前参与板块范围保持不变。 diff --git a/py-client/sdk/trade.py b/py-client/sdk/trade.py index 5e6262b..5cb3cb8 100644 --- a/py-client/sdk/trade.py +++ b/py-client/sdk/trade.py @@ -8,6 +8,64 @@ PR_TYPE_LATEST = 5 QUICK_TRADE_NOW = 2 ORDER_SIDE_BY_OFFSET = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"} +# QMT 的发行数据按市场再分一层时使用的市场键。 +_IPO_MARKETS = {"SH", "SZ", "BJ"} + + +def _ipo_stock_code(code: Any, info: dict[str, Any]) -> str: + """把 QMT 的证券代码键补成全码 ``600000.SH``。""" + text = str(code or "").strip() + if not text or "." in text: + return text + market = str( + info.get("market") or info.get("exchange") or info.get("ExchangeID") or "" + ).strip().upper() + return f"{text}.{market}" if market else text + + +def _ipo_entry(code: Any, info: dict[str, Any]) -> dict[str, Any]: + """保留原始发行信息,并补出候选列表使用的 ``stock`` 字段。""" + entry = dict(info) + stock = _ipo_stock_code(code, entry) + if stock: + entry["stock"] = stock + return entry + + +def _ipo_candidates(response: Any) -> list[dict[str, Any]]: + """把 ``/api/trade/ipo_data`` 的响应规范化为候选列表。 + + QMT 的 ``get_ipo_data(type)`` 返回 ``{证券代码: 发行信息}`` 字典,部分 + 版本再按市场分一层 ``{市场: {证券代码: 发行信息}}``,旧版服务端还会包 + 一层 ``{"data": ...}``。空响应表示当日没有可申购标的;无法识别的结构抛 + ``ValueError``,避免把接口异常静默当成“今日无新股”。 + """ + if isinstance(response, dict) and len(response) == 1 and "data" in response: + response = response["data"] + if response is None: + return [] + if isinstance(response, list): + # 列表逐项交给策略层校验,单条异常不影响其他候选。 + return list(response) + if not isinstance(response, dict): + raise ValueError(f"unsupported IPO response type: {type(response).__name__}") + if not response: + return [] + if not all(isinstance(value, dict) for value in response.values()): + raise ValueError("IPO response values must be objects") + + candidates: list[dict[str, Any]] = [] + for key, value in response.items(): + market = str(key).strip().upper() + if market in _IPO_MARKETS and all(isinstance(item, dict) for item in value.values()): + for code, info in value.items(): + entry = dict(info) + entry.setdefault("market", market) + candidates.append(_ipo_entry(code, entry)) + else: + candidates.append(_ipo_entry(key, value)) + return candidates + class TradeMixin: def passorder( @@ -43,14 +101,15 @@ class TradeMixin: ) def ipo_data(self, ipo_type: str = "STOCK") -> list[dict[str, Any]]: - """Return today's IPO candidates from the QMT REST service.""" + """Return today's IPO candidates from the QMT REST service. + + QMT 按证券代码返回字典,这里统一成候选列表,字段名保持不变。 + """ response = self._post_json( "/api/trade/ipo_data", {"type": str(ipo_type).strip().upper()}, ) - if not isinstance(response, list): - raise ValueError('IPO response must be a list') - return response + return _ipo_candidates(response) def cancel_by_id(self, order_id: str) -> dict[str, Any]: diff --git a/py-client/tests/test_ipo.py b/py-client/tests/test_ipo.py index 6304a87..d079d1d 100644 --- a/py-client/tests/test_ipo.py +++ b/py-client/tests/test_ipo.py @@ -14,6 +14,31 @@ from sdk.trade import TradeMixin from strategy.ipo import boot +class IPOResponseClient(TradeMixin): + """真实解析 + 模拟传输:用于验证 QMT 原始响应到下单的完整链路。""" + + def __init__(self, payload, orders=None): + self.payload = payload + self._orders = orders or [] + self.submitted = [] + + def _post_json(self, path, body=None): + return self.payload + + def orders(self): + return self._orders + + def passorder(self, **kwargs): + self.submitted.append(kwargs) + return {'status': 'success'} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + class IPOTests(unittest.TestCase): def setUp(self): temp = tempfile.TemporaryDirectory() @@ -151,6 +176,17 @@ class IPOTests(unittest.TestCase): self.client.passorder.assert_called_once() self.assertEqual(len(self.records()), 1) + def test_code_keyed_qmt_response_submits_subscription(self): + client = IPOResponseClient( + {'301001.SZ': {'issuePrice': 12.5, 'maxPurchaseNum': 15000}}) + with patch.object(boot, 'Client', return_value=client): + self.assertEqual(boot.AutoBuyIpo(), 1) + self.assertEqual(len(client.submitted), 1) + self.assertEqual(client.submitted[0]['stock'], '301001.SZ') + self.assertEqual(client.submitted[0]['volume'], 15000) + self.assertEqual(client.submitted[0]['price'], 12.5) + self.assertEqual([r['status'] for r in self.records()], ['pending']) + def test_supported_codes_and_numeric_strings(self): for stock in ['600001.SH', '688001.SH', '689001.SH', '000001.SZ', '001001.SZ', '002001.SZ', '003001.SZ', '300001.SZ', '301001.SZ']: @@ -161,14 +197,56 @@ class IPOTests(unittest.TestCase): class IPOResponseTests(unittest.TestCase): - def test_only_list_response_is_accepted(self): + def parsed(self, value): client = TradeMixin() - for value in [None, {}, {'error': 'bad'}, '', 0, False]: - client._post_json = Mock(return_value=value) + client._post_json = Mock(return_value=value) + return client.ipo_data() + + def test_code_keyed_mapping_is_parsed(self): + payload = { + '301001.SZ': {'issuePrice': 12.5, 'maxPurchaseNum': 15000, 'stockName': '示例'}, + '601127.SH': {'issuePrice': 4.09, 'maxPurchaseNum': 15000}, + } + self.assertEqual(self.parsed(payload), [ + dict(stock='301001.SZ', issuePrice=12.5, maxPurchaseNum=15000, stockName='示例'), + dict(stock='601127.SH', issuePrice=4.09, maxPurchaseNum=15000), + ]) + + def test_bare_code_uses_market_field(self): + payload = {'301001': {'market': 'SZ', 'issuePrice': 12.5, 'maxPurchaseNum': 15000}} + self.assertEqual(self.parsed(payload), [ + dict(stock='301001.SZ', market='SZ', issuePrice=12.5, maxPurchaseNum=15000), + ]) + + def test_market_bucketed_mapping_is_flattened(self): + payload = { + 'SH': {'601127': {'issuePrice': 4.09, 'maxPurchaseNum': 15000}}, + 'SZ': {'301001': {'issuePrice': 12.5, 'maxPurchaseNum': 15000}}, + } + self.assertEqual(self.parsed(payload), [ + dict(stock='601127.SH', market='SH', issuePrice=4.09, maxPurchaseNum=15000), + dict(stock='301001.SZ', market='SZ', issuePrice=12.5, maxPurchaseNum=15000), + ]) + + def test_legacy_data_wrapper_is_unwrapped(self): + payload = {'data': {'301001.SZ': {'issuePrice': 12.5, 'maxPurchaseNum': 15000}}} + self.assertEqual(self.parsed(payload), [ + dict(stock='301001.SZ', issuePrice=12.5, maxPurchaseNum=15000), + ]) + + def test_list_response_passes_through(self): + payload = [dict(stock='600001.SH', issuePrice=10, maxPurchaseNum=100)] + self.assertEqual(self.parsed(payload), payload) + + def test_empty_response_means_no_candidate(self): + for value in [None, {}, [], {'SH': {}, 'SZ': {}}]: + with self.subTest(value=value): + self.assertEqual(self.parsed(value), []) + + def test_unrecognised_structure_raises(self): + for value in ['', 0, False, 'unexpected', {'error': 'bad'}, {'SH': 'x'}]: with self.subTest(value=value), self.assertRaises(ValueError): - client.ipo_data() - client._post_json = Mock(return_value=[]) - self.assertEqual(client.ipo_data(), []) + self.parsed(value) if __name__ == '__main__':