113 lines
5.1 KiB
Python
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
|