This commit is contained in:
2026-09-12 16:25:34 +08:00
parent 7a7049ce44
commit 554dd0f4cb
13 changed files with 646 additions and 93 deletions

View File

@@ -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

View File

@@ -36,7 +36,7 @@ def StartTrend() -> None:
assets = portfolio.assets
positions = list(portfolio.positions.values())
cache_portfolio(config.account_config.account_id, assets, positions, client.deals())
order_book = OrderBook("trend")
order_book = OrderBook()
order_book.refresh(client, portfolio.orders)
# 获取本策略的信号开仓数据

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from concurrent.futures import Future, ThreadPoolExecutor
import config
from libs.calc import trading_time
from libs.grid_take_profit import GridTrailingTracker
from .profit import ZTProfitTracker
from libs.market import market_allow_open
from libs.order import OrderBook
from libs.overview import Overview
@@ -33,8 +33,8 @@ def StartZT() -> None:
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="zt")
run = Runtime(
client=client, global_cfg=config.global_config, account_cfg=config.account_config,
orders=OrderBook('zt'), open_watch=DipWatch(), add_watch=DipWatch(),
profit_tracker=GridTrailingTracker(config.account_config.grid_step_pct),
orders=OrderBook(), open_watch=DipWatch(), add_watch=DipWatch(),
profit_tracker=ZTProfitTracker(config.account_config.grid_step_pct),
executor=executor
)
@@ -56,7 +56,8 @@ def StartZT() -> None:
config.account_config.account_id, len(signals), len(positions))
cache_portfolio(config.account_config.account_id, assets, positions, deals)
state.sync_account(positions, deals, initialize=initialize)
run.orders.refresh(client, portfolio.orders)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(client, portfolio.orders, cancel_prefix='zt-')
Overview(assets, positions, config.account_config)
DEFAULT_TICK_INTERVAL = 30
@@ -111,9 +112,11 @@ def RunOnce(run: Runtime, state: State, signals: list[SignalItem]) -> None:
assets = portfolio.assets
positions = list(portfolio.positions.values())
position_codes = list(portfolio.positions)
cache_portfolio(run.account_cfg.account_id, assets, positions, deals)
state.sync_account(positions, deals)
run.orders.refresh(run.client, portfolio.orders)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
except Exception:
log.exception("[Portfolio] 刷新账户快照失败")
return

View File

@@ -213,7 +213,8 @@ def handle_loss(
def _position_key(runtime: Runtime, code: str) -> str:
return f"{runtime.account_cfg.account_id}:{code}"
# tracker 为该账户的 ZT Runtime 独享,与 sync_positions 使用同一个键。
return code
def get_add_num(hands: int, market_value: float) -> int:

View File

@@ -0,0 +1,31 @@
"""ZT 按已同步的仓位及实际成本管理止盈峰值。"""
from libs.grid_take_profit import GridTrailingTracker
class ZTProfitTracker(GridTrailingTracker):
def __init__(self, step: float = 1.0):
super().__init__(step)
self._bases: dict[str, tuple] = {}
def sync_positions(self, positions, state) -> None:
# 与交易线程串行执行;提交委托本身不会改变这里的基准。
current = {}
for position in positions:
code = position.stock_code
row = state.get_by_code(code)
if position.volume <= 0 or not row:
continue
bucket = 'added' if row.get('added_qty', 0) > 0 else 'base'
cost = row.get('added_price', 0) if bucket == 'added' else position.open_price
current[code] = (bucket, cost, row.get(f'{bucket}_order_local_id', ''),
row.get(f'{bucket}_created_at', ''))
for code in self._bases.keys() | current.keys():
if code in state.blocked_codes:
continue
if self._bases.get(code) != current.get(code):
self.clear(code)
if code in current:
self._bases[code] = current[code]
else:
self._bases.pop(code, None)