feat ipo
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
18
py-client/libs/lockfile.py
Normal file
18
py-client/libs/lockfile.py
Normal 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")
|
||||
@@ -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()
|
||||
|
||||
3
py-client/strategy/ipo/__init__.py
Normal file
3
py-client/strategy/ipo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .boot import AutoBuyIpo
|
||||
|
||||
__all__ = ["AutoBuyIpo"]
|
||||
BIN
py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc
Normal file
BIN
py-client/strategy/ipo/__pycache__/boot.cpython-311.pyc
Normal file
Binary file not shown.
@@ -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
140
py-client/tests/test_ipo.py
Normal 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()
|
||||
Reference in New Issue
Block a user