Files
big-qmt/py-client/strategy/ipo/boot.py
2026-09-12 16:25:34 +08:00

113 lines
5.1 KiB
Python

"""IPO 申购:账户隔离、提交前占位、通过券商委托核对结果。"""
import hashlib
import json
import logging
import math
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
import config
from sdk import Client
from libs.calc import trading_time
from libs.lockfile import claim_json, replace_json
import secrets
def is_target_stock(symbol: str) -> bool:
"""保留原板块范围,同时校验完整代码和对应交易所。"""
return isinstance(symbol, str) and re.fullmatch(
r'(?:60[0-9]{4}|68[89][0-9]{3})\.SH|(?:00[0-3][0-9]{3}|30[01][0-9]{3})\.SZ',
symbol,
) is not None
def _candidate(item: dict) -> tuple[str, float, int]:
if not isinstance(item, dict):
raise ValueError('IPO candidate must be an object')
stock = item.get('stock')
if not isinstance(stock, str) or not is_target_stock(stock.strip()):
raise ValueError('Invalid or unsupported IPO stock code')
if isinstance(item.get('issuePrice'), bool) or isinstance(item.get('maxPurchaseNum'), bool):
raise ValueError('IPO price and volume cannot be boolean')
price = float(item['issuePrice'])
try:
quantity = Decimal(str(item['maxPurchaseNum']))
except InvalidOperation as exc:
raise ValueError('Invalid IPO volume') from exc
if not math.isfinite(price) or price <= 0:
raise ValueError('IPO price must be positive and finite')
if not quantity.is_finite() or quantity <= 0 or quantity != quantity.to_integral_value():
raise ValueError('IPO volume must be a positive integer')
return stock.strip(), price, int(quantity)
def _subscribe(client, orders, account: str, day: str, stock: str, price: float, volume: int) -> bool:
# 每次明确拒绝后的尝试单独占位,避免两个进程同时重试。
account_key = hashlib.sha256(account.encode('utf-8')).hexdigest()
folder = Path(config.global_config.qmt_data_dir) / 'ipo' / account_key / day / stock
paths = sorted(folder.glob('[0-9]*.json'), key=lambda p: int(p.stem))
path = paths[-1] if paths else None
record = json.loads(path.read_text(encoding='utf-8')) if path else None
if record and record['status'] == 'confirmed':
return False
matching = [o for o in orders if o.stock_code == stock and o.side == 'BUY'
and (o.insert_date.replace('-', '') == day
or (record and o.local_order_id == record['order_id']))]
# 已有其他有效/未知状态买单也阻止再申购,覆盖人工提交和旧版记录。
if any(str(o.order_status) != '57' or o.volume_traded > 0 for o in matching):
if record and any(o.local_order_id == record['order_id'] and str(o.order_status) == '56'
for o in matching):
record['status'] = 'confirmed'
replace_json(path, record)
logging.info('[IPO] %s 已有委托,保持防重', stock)
return False
if record:
rejected = any(o.local_order_id == record['order_id'] and str(o.order_status) == '57'
and o.volume_traded == 0 for o in matching)
if not rejected:
logging.info('[IPO] %s 结果待确认,暂不重发', stock)
return False
record['status'] = 'rejected'
replace_json(path, record)
attempt = int(path.stem) + 1 if path else 1
order_id = f'IPO-{secrets.token_hex(12)}'
record = dict(account=account, date=day, stock=stock, order_id=order_id,
status='pending', price=price, volume=volume)
if not claim_json(folder / f'{attempt:04d}.json', record):
return False
# HTTP 正常返回或异常均不代表最终结果;仅券商回报可改变 pending。
client.passorder(op_type=23, stock=stock, volume=volume, pr_type=11,
price=price, strategy_name='ipo', order_id=order_id)
logging.info('[IPO] %s 已提交 %d 股,等待委托确认,编号=%s', stock, volume, order_id)
return True
def AutoBuyIpo() -> int:
"""返回本次正常返回的提交数量,不代表最终申购成功。"""
now = datetime.now()
if not config.account_config.enable_auto_ipo or not trading_time(now):
return 0
submitted = 0
try:
account = str(config.account_config.account_id).strip()
if not account:
raise ValueError('IPO account ID is required')
with Client(config.global_config.qmt_base_url, config.global_config.qmt_token,
config.HTTP_TIMEOUT) as client:
candidates = client.ipo_data('STOCK')
orders = client.orders() # 查询失败时不提交,不能将未知当作无委托。
for item in candidates:
try:
stock, price, volume = _candidate(item)
submitted += _subscribe(client, orders, account, now.strftime('%Y%m%d'),
stock, price, volume)
except Exception:
logging.exception('[IPO] 单条申购处理失败,数据=%r', item)
except Exception:
logging.exception('[IPO] 自动申购任务失败')
return submitted