fix bug
This commit is contained in:
@@ -1,85 +1,112 @@
|
||||
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
|
||||
"""IPO 申购:账户隔离、提交前占位、通过券商委托核对结果。"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, time
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import config
|
||||
from sdk import Client
|
||||
from libs.calc import trading_time
|
||||
from libs.lockfile import is_lock,write_lockfile
|
||||
|
||||
IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0)))
|
||||
|
||||
|
||||
def AutoBuyIpo():
|
||||
"""安全执行一次新股申购,返回成功提交的证券数量。"""
|
||||
if not config.account_config.enable_auto_ipo:
|
||||
logging.info("[IPO] 自动申购未启用")
|
||||
return 0
|
||||
if not trading_time(datetime.now()):
|
||||
logging.info("[IPO] 非交易时间")
|
||||
return 0
|
||||
|
||||
try:
|
||||
with Client(
|
||||
config.global_config.qmt_base_url,
|
||||
config.global_config.qmt_token,
|
||||
config.HTTP_TIMEOUT,
|
||||
) as client:
|
||||
result = client.ipo_data("STOCK")
|
||||
for item in result:
|
||||
try:
|
||||
if not isinstance(item, dict):
|
||||
raise TypeError("IPO 数据项必须是字典")
|
||||
|
||||
stock = str(item.get("stock", "")).strip()
|
||||
if not is_target_stock(stock):
|
||||
continue
|
||||
|
||||
ipo_price = float(item["issuePrice"])
|
||||
max_purchase_num = int(item["maxPurchaseNum"])
|
||||
if ipo_price <= 0 or max_purchase_num <= 0:
|
||||
raise ValueError("发行价或申购额度必须大于 0")
|
||||
|
||||
lock_path = Path(config.global_config.qmt_data_dir) / f"{stock}.lock"
|
||||
if is_lock(lock_path):
|
||||
continue
|
||||
|
||||
client.passorder(
|
||||
op_type=23,
|
||||
stock=stock,
|
||||
volume=max_purchase_num,
|
||||
pr_type=11,
|
||||
price=ipo_price,
|
||||
strategy_name="ipo",
|
||||
)
|
||||
write_lockfile(lock_path)
|
||||
logging.info(
|
||||
"[IPO] %s 申购,发行价:%s 可申购额度:%s",
|
||||
stock,
|
||||
ipo_price,
|
||||
max_purchase_num,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("[IPO] 单条申购处理失败,数据=%r", item)
|
||||
except Exception:
|
||||
logging.exception("[IPO] 自动申购任务失败")
|
||||
from libs.lockfile import claim_json, replace_json
|
||||
import secrets
|
||||
|
||||
def is_target_stock(symbol: str) -> bool:
|
||||
"""
|
||||
判断是否为上证、深证、科创板的A股。
|
||||
symbol格式示例: '600519.SH', '000001.SZ'
|
||||
"""
|
||||
# 提取纯数字代码
|
||||
code = symbol.split(".")[0]
|
||||
|
||||
# 判断是否为合规板块
|
||||
if code.startswith(('60', '688', '689')): # 沪市主板 + 科创板
|
||||
return True
|
||||
if code.startswith(('000', '001', '002', '003', '300', '301')): # 深市主板 + 创业板
|
||||
return True
|
||||
|
||||
return False
|
||||
"""保留原板块范围,同时校验完整代码和对应交易所。"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user