fix ipo bug
This commit is contained in:
15
docs/api.md
15
docs/api.md
@@ -323,6 +323,21 @@ passorder(opType, orderType, account_id, stockCode, prType, price,
|
|||||||
|
|
||||||
响应直接序列化底层结果,不包装或重命名字段,具体结构由 QMT 提供。使用默认参数也需发送 `{}`。
|
响应直接序列化底层结果,不包装或重命名字段,具体结构由 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 版本
|
## 10. Python 版本
|
||||||
|
|
||||||
`GET /api/sys/python_version`,无参数。
|
`GET /api/sys/python_version`,无参数。
|
||||||
|
|||||||
@@ -16,4 +16,4 @@
|
|||||||
|
|
||||||
损坏或无法读取的记录不会触发重新提交;记录写入失败也不会提交。若长期查不到回报,需要先人工核对柜台结果,不能直接删除待确认记录后重跑。
|
损坏或无法读取的记录不会触发重新提交;记录写入失败也不会提交。若长期查不到回报,需要先人工核对柜台结果,不能直接删除待确认记录后重跑。
|
||||||
|
|
||||||
接口响应必须是列表,合法空列表表示无候选;非列表响应记录错误。候选要求完整代码及对应交易所、有限正价格、正整数数量。当前参与板块范围保持不变。
|
接口响应是 QMT `get_ipo_data()` 的原样转发:以证券代码为键的字典(部分版本再按市场分一层),SDK 在 `sdk/trade.py` 规范化为候选列表并补全 `stock` 全码;空响应表示当日无候选,无法识别的结构抛错并记录,不静默当作"今日无新股"。候选要求完整代码及对应交易所、有限正价格、正整数数量。当前参与板块范围保持不变。
|
||||||
|
|||||||
@@ -8,6 +8,64 @@ PR_TYPE_LATEST = 5
|
|||||||
QUICK_TRADE_NOW = 2
|
QUICK_TRADE_NOW = 2
|
||||||
ORDER_SIDE_BY_OFFSET = {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}
|
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:
|
class TradeMixin:
|
||||||
def passorder(
|
def passorder(
|
||||||
@@ -43,14 +101,15 @@ class TradeMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def ipo_data(self, ipo_type: str = "STOCK") -> list[dict[str, Any]]:
|
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(
|
response = self._post_json(
|
||||||
"/api/trade/ipo_data",
|
"/api/trade/ipo_data",
|
||||||
{"type": str(ipo_type).strip().upper()},
|
{"type": str(ipo_type).strip().upper()},
|
||||||
)
|
)
|
||||||
if not isinstance(response, list):
|
return _ipo_candidates(response)
|
||||||
raise ValueError('IPO response must be a list')
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_by_id(self, order_id: str) -> dict[str, Any]:
|
def cancel_by_id(self, order_id: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -14,6 +14,31 @@ from sdk.trade import TradeMixin
|
|||||||
from strategy.ipo import boot
|
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):
|
class IPOTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
temp = tempfile.TemporaryDirectory()
|
temp = tempfile.TemporaryDirectory()
|
||||||
@@ -151,6 +176,17 @@ class IPOTests(unittest.TestCase):
|
|||||||
self.client.passorder.assert_called_once()
|
self.client.passorder.assert_called_once()
|
||||||
self.assertEqual(len(self.records()), 1)
|
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):
|
def test_supported_codes_and_numeric_strings(self):
|
||||||
for stock in ['600001.SH', '688001.SH', '689001.SH', '000001.SZ',
|
for stock in ['600001.SH', '688001.SH', '689001.SH', '000001.SZ',
|
||||||
'001001.SZ', '002001.SZ', '003001.SZ', '300001.SZ', '301001.SZ']:
|
'001001.SZ', '002001.SZ', '003001.SZ', '300001.SZ', '301001.SZ']:
|
||||||
@@ -161,14 +197,56 @@ class IPOTests(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class IPOResponseTests(unittest.TestCase):
|
class IPOResponseTests(unittest.TestCase):
|
||||||
def test_only_list_response_is_accepted(self):
|
def parsed(self, value):
|
||||||
client = TradeMixin()
|
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):
|
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||||
client.ipo_data()
|
self.parsed(value)
|
||||||
client._post_json = Mock(return_value=[])
|
|
||||||
self.assertEqual(client.ipo_data(), [])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Reference in New Issue
Block a user