This commit is contained in:
2026-08-29 01:50:09 +08:00
parent 28e91366d6
commit 846d6c03e0
11 changed files with 490 additions and 15 deletions

View File

@@ -49,6 +49,7 @@ class AccountConfig:
grid_step_pct: float = 1
min_profit_pct: float = 0
enable_loss_add_position: bool = False
enable_auto_ipo: bool = True
signal_allow: list[str] = field(default_factory=list)
excluded_codes: list[str] = field(default_factory=list)

View File

@@ -7,5 +7,6 @@ grid_step_pct: 1
min_profit_pct: 2
strategy: trend
enable_loss_add_position: True
enable_auto_ipo: True
excluded_codes:
- "00000.SZ"

View File

@@ -1,5 +1,17 @@
from .calc import calc_buy_volume, trading_time
from .lockfile import is_lock, write_lockfile
from .market import market_allow_open, status
from .signal import SignalItem, SignalResult, fetch_signal, init_signals
__all__ = ["calc_buy_volume", "trading_time", "market_allow_open", "status", "SignalItem", "SignalResult", "fetch_signal", "init_signals"]
__all__ = [
"calc_buy_volume",
"trading_time",
"is_lock",
"write_lockfile",
"market_allow_open",
"status",
"SignalItem",
"SignalResult",
"fetch_signal",
"init_signals",
]

View File

@@ -0,0 +1,18 @@
"""简单的文件锁标记工具。"""
from __future__ import annotations
from os import PathLike
from pathlib import Path
def is_lock(file_path: str | PathLike[str]) -> bool:
"""判断指定的锁文件是否存在。"""
return Path(file_path).is_file()
def write_lockfile(file_path: str | PathLike[str]) -> None:
"""创建锁文件;父目录不存在时自动创建。"""
path = Path(file_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("LOCK", encoding="utf-8")

View File

@@ -124,7 +124,7 @@ def main() -> int:
configure_logging(config.global_config.qmt_data_dir)
wait_for_qmt_api()
# 自动打新
# 自动打新与主策略隔离;申购服务失败不能阻止趋势策略启动。
AutoBuyIpo()
STRATEGIES[config.account_config.strategy].start_strategy()

View File

@@ -0,0 +1,3 @@
from .boot import AutoBuyIpo
__all__ = ["AutoBuyIpo"]

View File

@@ -1,7 +1,33 @@
from sdk import Client
import config
"""新股自动申购,提供交易日校验、券商对账和本地幂等保护。"""
from __future__ import annotations
import json
import logging
from datetime import datetime, time
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_STRATEGY_NAME = "IPO_SUBSCRIBE"
IPO_REMARKS = {IPO_STRATEGY_NAME, "新股申购"}
IPO_SESSIONS = ((time(9, 30), time(11, 30)), (time(13, 0), time(15, 0)))
TRADING_CALENDAR_SYMBOL = "000001.SH"
def AutoBuyIpo(now: datetime | None = None) -> int:
"""安全执行一次新股申购,返回成功提交的证券数量。"""
if not config.account_config.enable_auto_ipo:
logging.info("[IPO] 自动申购未启用")
return 0
if not trading_time():
logging.info("[IPO] 非交易时间")
return 0
def AutoBuyIpo() -> None:
client = Client(
config.global_config.qmt_base_url,
config.global_config.qmt_token,
@@ -10,13 +36,17 @@ def AutoBuyIpo() -> None:
result = client.ipo_data("STOCK")
for stock in result:
ipo_price = result[stock]['issuePrice'] # 发行价
maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度
client.passorder(
op_type=23,
stock=stock,
volume=maxPurchaseNum,
pr_type=11,
price=ipo_price,
strategy_name="新股申购",
)
lp = Path(config.global_config.qmt_data_dir/f"{stock}.lock")
if is_lock(lp):
ipo_price = result[stock]['issuePrice'] # 发行价
maxPurchaseNum = result[stock]['maxPurchaseNum'] # 可申购额度
client.passorder(
op_type=23,
stock=stock,
volume=maxPurchaseNum,
pr_type=11,
price=ipo_price,
strategy_name="新股申购",
)
write_lockfile(lp)

140
py-client/tests/test_ipo.py Normal file
View File

@@ -0,0 +1,140 @@
from __future__ import annotations
import unittest
from datetime import datetime
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from strategy.ipo.boot import AutoBuyIpo
RUN_TIME = datetime(2026, 8, 28, 10, 0)
class FakeClient:
def __init__(self, candidates=None, orders=None, deals=None, fail_codes=None):
self.candidates = candidates or {}
self.orders = orders or []
self.deal_rows = deals or []
self.fail_codes = set(fail_codes or [])
self.submissions = []
self.closed = False
def __enter__(self):
return self
def __exit__(self, *_args):
self.closed = True
def trading_dates(self, *_args):
return ["20260828"]
def trade_detail_data(self, datatype):
self.assert_order_type = datatype
return self.orders
def deals(self):
return self.deal_rows
def ipo_data(self, ipo_type):
self.assert_ipo_type = ipo_type
return self.candidates
def passorder(self, **kwargs):
code = kwargs["stock"]
self.submissions.append(kwargs)
if code in self.fail_codes:
raise RuntimeError("simulated rejection")
return {"status": "success", "order_ref": f"ref-{code}"}
class AutoBuyIpoTests(unittest.TestCase):
def _configs(self, directory, enabled=True):
return (
SimpleNamespace(
qmt_base_url="http://qmt",
qmt_token="token",
qmt_data_dir=directory,
),
SimpleNamespace(account_id="account-A", enable_auto_ipo=enabled),
)
def test_disabled_does_not_create_client(self):
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory, enabled=False)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client") as client_factory,
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 0)
client_factory.assert_not_called()
def test_local_record_prevents_duplicate_after_restart(self):
candidates = {
"688001.SH": {"issuePrice": 10, "maxPurchaseNum": 1000},
}
first = FakeClient(candidates=candidates)
second = FakeClient(candidates=candidates)
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client", side_effect=[first, second]),
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 1)
self.assertEqual(AutoBuyIpo(RUN_TIME), 0)
self.assertEqual(len(first.submissions), 1)
self.assertEqual(second.submissions, [])
self.assertTrue(first.closed)
self.assertTrue(second.closed)
def test_broker_order_prevents_duplicate(self):
candidates = {
"688001.SH": {"issuePrice": 10, "maxPurchaseNum": 1000},
}
client = FakeClient(
candidates=candidates,
orders=[{
"m_strInstrumentID": "688001",
"m_strInsertDate": "20260828",
"m_strRemark": "IPO_SUBSCRIBE",
}],
)
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client", return_value=client),
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 0)
self.assertEqual(client.submissions, [])
def test_one_rejection_does_not_stop_other_candidates(self):
candidates = {
"688001.SH": {"issuePrice": 10, "maxPurchaseNum": 1000},
"688002.SH": {"issuePrice": 20, "maxPurchaseNum": 500},
}
client = FakeClient(candidates=candidates, fail_codes={"688001.SH"})
with TemporaryDirectory() as directory:
global_cfg, account_cfg = self._configs(directory)
with (
patch("strategy.ipo.boot.config.global_config", global_cfg),
patch("strategy.ipo.boot.config.account_config", account_cfg),
patch("strategy.ipo.boot.Client", return_value=client),
):
self.assertEqual(AutoBuyIpo(RUN_TIME), 1)
self.assertEqual(
[item["stock"] for item in client.submissions],
["688001.SH", "688002.SH"],
)
self.assertTrue(client.closed)
if __name__ == "__main__":
unittest.main()