feat libs,sdk,trend

This commit is contained in:
2026-09-07 14:04:26 +08:00
parent d37f9edefc
commit e320de3241
30 changed files with 515 additions and 507 deletions

View File

@@ -174,7 +174,7 @@ class PortfolioHandler(BaseHandler):
result = { result = {
"assets": format_assets(account_data), "assets": format_assets(account_data),
"positions": format_holding(positions), "positions": format_holding(positions),
"orders": [fixed_fields(order) for order in orders], "orders": format_orders(orders),
} }
self.write_json(result) self.write_json(result)
@@ -183,8 +183,7 @@ class PortfolioHandler(BaseHandler):
class HoldingHandler(BaseHandler): class HoldingHandler(BaseHandler):
def get(self): def get(self):
positions = safe_call(get_trade_detail_data, self.acc(), 'stock', 'position') or [] positions = safe_call(get_trade_detail_data, self.acc(), 'stock', 'position') or []
holding = format_holding(positions) self.write_json(format_holding(positions))
self.write_json({"data": holding})
class OrgHandler(BaseHandler): class OrgHandler(BaseHandler):
def get(self, handler_type): def get(self, handler_type):
@@ -207,22 +206,18 @@ class OrgHandler(BaseHandler):
# get_trade_detail_data('account') - Query account assets # get_trade_detail_data('account') - Query account assets
class AssetsHandler(BaseHandler): class AssetsHandler(BaseHandler):
def get(self): def get(self):
_data = safe_call(get_trade_detail_data, self.acc(), 'stock', 'account') _data = safe_call(get_trade_detail_data, self.acc(), 'stock', 'account') or []
self.write_json(format_assets(_data)) self.write_json(format_assets(_data))
class OrderHandler(BaseHandler): class OrderHandler(BaseHandler):
def get(self): def get(self):
ret = safe_call(get_trade_detail_data, self.acc(), 'stock', 'order') ret = safe_call(get_trade_detail_data, self.acc(), 'stock', 'order') or []
if ret is None: self.write_json(format_orders(ret))
ret = []
result = [fixed_fields(obj) for obj in ret]
self.write_json(result)
class DealHandler(BaseHandler): class DealHandler(BaseHandler):
def get(self): def get(self):
deals = safe_call(get_trade_detail_data, self.acc(), 'stock', 'deal') or [] deals = safe_call(get_trade_detail_data, self.acc(), 'stock', 'deal') or []
rets = [fixed_fields(deal) for deal in deals] self.write_json(format_deals(deals))
self.write_json({"deals": rets})
# ContextInfo.get_full_tick() - Get full tick data # ContextInfo.get_full_tick() - Get full tick data
class FullTickHandler(BaseHandler): class FullTickHandler(BaseHandler):
@@ -330,56 +325,70 @@ def format_holding(positions):
for position in positions: for position in positions:
stock = position.m_strInstrumentID + '.' + position.m_strExchangeID stock = position.m_strInstrumentID + '.' + position.m_strExchangeID
holding[stock] = { holding[stock] = {
'StockCode': stock, 'stock_code': stock,
'TradeID':position.m_strTradeID, 'stock_name': position.m_strInstrumentName,
'StockName': position.m_strInstrumentName, 'direction': position.m_nDirection,
'Direction': position.m_nDirection, 'volume': position.m_nVolume,
'Volume': position.m_nVolume, 'open_price': position.m_dOpenPrice,
'OpenPrice': position.m_dOpenPrice, 'open_cost':position.m_dOpenCost,
'OpenCost':position.m_dOpenCost, 'float_profit': position.m_dFloatProfit,
'FloatProfit': position.m_dFloatProfit, 'market_value': position.m_dMarketValue,
'MarketValue': position.m_dMarketValue, 'stock_holder': position.m_strStockHolder,
'StockHolder': position.m_strStockHolder, 'frozen_volume': position.m_nFrozenVolume,
'FrozenVolume': position.m_nFrozenVolume, 'can_use_volume': position.m_nCanUseVolume,
'CanUseVolume': position.m_nCanUseVolume, 'on_road_volume': position.m_nOnRoadVolume,
'OnRoadVolume': position.m_nOnRoadVolume, 'yesterday_volume': position.m_nYesterdayVolume,
'YesterdayVolume': position.m_nYesterdayVolume, 'last_price': position.m_dLastPrice,
'LastPrice': position.m_dLastPrice, 'profit_rate': position.m_dProfitRate,
'ProfitRate': position.m_dProfitRate, 'future_trade_type': position.m_eFutureTradeType,
'FutureTradeType': position.m_eFutureTradeType, 'expire_date': position.m_strExpireDate
'ExpireDate': position.m_strExpireDate
} }
return holding return holding
TRADE_DETAIL_FIELDS = ( def format_orders(orders):
'm_strOrderSysID', 'm_strInstrumentID', 'm_strExchangeID', """显式转换委托/成交字段,保留原始数值类型。"""
'm_strInstrumentName', 'm_nOffsetFlag', 'm_nOrderStatus', result = []
'm_nVolumeTotal', 'm_nVolumeTraded', 'm_nOrderTime', for order in orders:
'm_strInsertDate', 'm_strInsertTime', 'm_strRemark', result.append({
'm_dPrice', 'm_dTradePrice', 'm_dTradeAmount', 'stock_code': order.m_strInstrumentID + '.' + order.m_strExchangeID,
) 'order_sys_id': order.m_strOrderSysID,
MISSING = object() 'ref': order.m_nRef,
'order_ref': order.m_strOrderRef,
'direction': order.m_nDirection,
def fixed_fields(obj, fields=TRADE_DETAIL_FIELDS): 'offset_flag': order.m_nOffsetFlag,
result = {} 'limit_price': order.m_dLimitPrice,
for field in fields: 'volume_total_original': order.m_nVolumeTotalOriginal,
try: 'volume_traded': order.m_nVolumeTraded,
value = getattr(obj, field, MISSING) 'volume_total': order.m_nVolumeTotal,
except TypeError: 'traded_price': order.m_dTradedPrice,
continue 'trade_amount': order.m_dTradeAmount,
if value is MISSING: 'insert_date': order.m_strInsertDate,
continue 'insert_time': order.m_strInsertTime,
if not callable(value): 'remark': order.m_strRemark,
result[field] = str(value) 'order_status': order.m_nOrderStatus,
if not result: })
attrs = getattr(obj, '__dict__', {})
result = {
key: str(value) for key, value in attrs.items()
if not key.startswith('_') and not callable(value)
}
return result return result
def format_deals(deals):
"""显式转换委托/成交字段,保留原始数值类型。"""
result = []
for d in deals:
result.append({
'stock_code': d.m_strInstrumentID + '.' + d.m_strExchangeID,
'order_sys_id': d.m_strOrderSysID,
'ref': d.m_nRef,
'order_ref': d.m_strOrderRef,
'direction': d.m_nDirection,
'offset_flag': d.m_nOffsetFlag,
'price': d.m_dPrice,
'volume': d.m_nVolume,
'trade_amount': d.m_dTradeAmount,
'trade_date': d.m_strTradeDate,
'trade_time': d.m_strTradeTime,
'remark': d.m_strRemark,
'close_profit': d.m_dCloseProfit,
})
return result
# ============= Route registration ============= # ============= Route registration =============
def make_app(): def make_app():

View File

@@ -19,39 +19,22 @@ zt_max_price: 200
## SQLite 状态存储 ## SQLite 状态存储
`libs/orderbook.py` 使用标准库 `sqlite3`,数据库路径为 `libs/orderbook.py` 使用标准库 `sqlite3`,数据库路径为
`{qmt_data_dir}/zt_{account_id}_state.db`,每个账户/策略独立存储,由一个策略实例串行更新。 `{qmt_data_dir}/zt_{account_id}_state.db`,每个账户/策略由单个实例串行更新。
启动时仅创建当前表结构和索引,不执行数据迁移或旧 JSON 导入。 启动时仅创建当前表结构和索引,不执行迁移或旧 JSON 导入。
`OrderBook` 初始化后调用 `load()`,填充 `positions`(按代码)、`deals`(按系统订单号) 两表使用 SDK 同名字段;另有自增主键 `id`,成交表增加从 `remark` 提取的 `order_local_id`
`deals_sys_ids`(系统订单号集合)。`sync_deals(list[DealItem])` 根据该集合过滤已保存及
同批重复成交,再以单个事务批量插入,提交成功后刷新缓存;不修改持仓。
`sys_order_id` 对应 API 的 `m_strOrderSysID``local_order_id``m_strRemark` 的首段提取。
| 表 | 字段与用途 | 索引 | | 表 | 数据模型 | 索引 |
| --- | --- | --- | | --- | --- | --- |
| `positions` | 自增 `id`、股票代码 `code`、底仓订单/数量/成本 `base_order_id/base_qty/base_cost`、补仓订单/次数/数量/成本 `added_order_id/added_num/added_qty/added_cost`、状态 `status` | `code` 唯一索引;`base_order_id``added_order_id` | | `positions` | `PositionItem``stock_code``stock_name``direction``volume``open_price``open_cost``float_profit``market_value``stock_holder``frozen_volume``can_use_volume``on_road_volume``yesterday_volume``last_price``profit_rate``future_trade_type``expire_date` | `stock_code` 唯一索引 |
| `deals` | `id`、系统/本地订单号、证券信息、方向、API 状态、剩余/成交数量、委托日期时间、备注、委托价格及成交均价/金额,字段映射见下表 | `id` 主键;`sys_order_id` 唯一;`local_order_id``(code, insert_date)``(insert_date, insert_time)` | | `deals` | `DealItem``stock_code``order_sys_id``ref``order_ref``direction``offset_flag``price``volume``trade_amount``trade_date``trade_time``remark``close_profit`,以及 `order_local_id` | `order_sys_id` 唯一索引;`order_local_id``stock_code``trade_date` |
SDK 的 `DealItem` 与成交表业务字段一致: `load()` 只更新 `positions``deals``deals_sys_ids` 缓存,无返回值。
`sync_positions(list[PositionItem])` 保存完整持仓快照,同一证券更新时保留自增 ID。
`sync_deals(list[DealItem])` 按系统订单号去重后批量写入;同批重复记录仅写一次。
`order_local_id``DealItem.local_order_id``remark` 首段),为空时拒绝写入。
成交日期规范为 `YYYY-MM-DD`,金额缺失时用成交价格乘数量补足。
| API 字段 | SDK / SQLite 字段 | ZT 使用 `volume/open_price` 保存底仓数量与成本,做 T 轮次从成交历史恢复,
| --- | --- | 不再使用持仓表的旧状态、底仓订单或补仓字段。买卖方向由 `offset_flag` 计算,
| `m_strOrderSysID` | `sys_order_id` | 本地订单号从 `remark` 提取。持仓与新增成交在同一事务提交,失败时回滚并恢复内存。
| `m_strInstrumentID``m_strExchangeID` | `instrument_id``exchange_id`,组合生成 `code` |
| `m_strInstrumentName` | `name` |
| `m_nOffsetFlag` | `offset_flag`,解析生成买卖方向 `side` |
| `m_nOrderStatus` | `status` |
| `m_nVolumeTotal``m_nVolumeTraded` | `remaining_volume``traded_volume` |
| `m_nOrderTime` | `order_time` |
| `m_strInsertDate``m_strInsertTime` | `insert_date``insert_time` |
| `m_strRemark` | `remark`,提取 `local_order_id` |
| `m_dPrice``m_dTradePrice``m_dTradeAmount` | `price``trade_price``trade_amount` |
API 返回已成交数据,每个系统订单号仅入库一次;`status` 保存 API 原值,不增加确认流程。
成交金额缺失时仅使用成交均价乘成交数量补足,不使用委托价格。
买卖方向使用 `side`ZT 通过本地委托号前缀区分底仓买入与做 T 买回。
ZT 根据实际成交数量及金额更新持仓,日期取 API 提供的 `insert_date`,入库时规范为 `YYYY-MM-DD`
首次接管持仓仅写持仓表。活动委托和重复下单检查由 `libs/order.py` 的委托簿负责。
持仓快照与新增成交在同一事务内保存,历史成交只追加;写入失败回滚数据库并恢复内存状态。
持仓更新按 `code` 保留原有自增 ID。ZT 将轮次状态写入 `status`,卖出、买回数量及均价、
轮次日期在重启时从 `deals` 重建补仓字段预留ZT 买回不计为补仓。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,6 +9,15 @@ import httpx
COLLECTOR_URL = "http://139.224.247.176:13499/collector" COLLECTOR_URL = "http://139.224.247.176:13499/collector"
def submit_trend_data() -> None:
"""每五分钟提交趋势策略的最新缓存,尚无快照时跳过。"""
from strategy.trend.boot import get_collector_snapshot
snapshot = get_collector_snapshot()
if snapshot is not None:
collector_push(*snapshot)
def _json_value(value: Any) -> Any: def _json_value(value: Any) -> Any:
"""Convert the QMT model values into values accepted by a JSON encoder.""" """Convert the QMT model values into values accepted by a JSON encoder."""
if is_dataclass(value) and not isinstance(value, type): if is_dataclass(value) and not isinstance(value, type):
@@ -27,7 +36,7 @@ def _json_value(value: Any) -> Any:
def collector_push(account_id: str, assets: Any, positions: Any) -> None: def collector_push(account_id: str, assets: Any, positions: Any) -> None:
"""Best-effort collector upload; never propagate errors to the caller.""" """[暂停] 数据收集提交太耗时超过200毫秒."""
try: try:
payload = _json_value( payload = _json_value(
{ {

View File

@@ -70,29 +70,29 @@ class OrderBook:
for item in orders: for item in orders:
# 不处理状态不对的 # 不处理状态不对的
if item.status not in TRACKED_STATUSES: if str(item.order_status) not in TRACKED_STATUSES:
continue continue
if item.status in BUSY_STATUSES: if str(item.order_status) in BUSY_STATUSES:
busy_keys.add(self._busy_key(item.side, item.code)) busy_keys.add(self._busy_key(item.side, item.stock_code))
# 清理过期的 # 清理过期的
if ( if (
item.created_at is not None item.created_at is not None
and item.local_order_id.startswith(f"{self.order_prefix}-") and item.local_order_id.startswith(f"{self.order_prefix}-")
and item.status in CANCELABLE_STATUSES and str(item.order_status) in CANCELABLE_STATUSES
and current - item.created_at > self.cancel_timeout_sec and current - item.created_at > self.cancel_timeout_sec
): ):
try: try:
client.cancel_by_id(item.id) client.cancel_by_id(item.order_sys_id)
canceled += 1 canceled += 1
logging.info( logging.info(
"[Order] 超时撤单,代码=%s,方向=%s,柜台订单=%s", "[Order] 超时撤单,代码=%s,方向=%s,柜台订单=%s",
item.code, item.stock_code,
item.side, item.side,
item.id, item.order_sys_id,
) )
except Exception: except Exception:
logging.exception( logging.exception(
"[Order] 撤单失败,保留在途状态,订单=%s", item.id "[Order] 撤单失败,保留在途状态,订单=%s", item.order_sys_id
) )
# 缓存本次有效订单 # 缓存本次有效订单

View File

@@ -1,62 +1,79 @@
"""SQLite 状态簿:每个账户/策略使用独立数据库,单个策略串行读写。""" """SQLite positions and deals, aligned with SDK models; one writer per database."""
from __future__ import annotations from __future__ import annotations
import math import math
import sqlite3 import sqlite3
from contextlib import closing from contextlib import closing
from dataclasses import asdict from dataclasses import asdict, fields
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from sdk import DealItem from sdk import DealItem, PositionItem
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS positions ( CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL, stock_code TEXT NOT NULL,
base_order_id TEXT NOT NULL DEFAULT '', stock_name TEXT NOT NULL DEFAULT '',
base_qty INTEGER NOT NULL DEFAULT 0 CHECK (base_qty >= 0), direction INTEGER,
base_cost REAL NOT NULL DEFAULT 0.0 CHECK (base_cost >= 0), volume INTEGER NOT NULL DEFAULT 0 CHECK (volume >= 0),
added_order_id TEXT NOT NULL DEFAULT '', open_price REAL NOT NULL DEFAULT 0,
added_num INTEGER NOT NULL DEFAULT 0 CHECK (added_num >= 0), open_cost REAL NOT NULL DEFAULT 0,
added_qty INTEGER NOT NULL DEFAULT 0 CHECK (added_qty >= 0), float_profit REAL NOT NULL DEFAULT 0,
added_cost REAL NOT NULL DEFAULT 0.0 CHECK (added_cost >= 0), market_value REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL stock_holder TEXT NOT NULL DEFAULT '',
frozen_volume INTEGER NOT NULL DEFAULT 0,
can_use_volume INTEGER NOT NULL DEFAULT 0,
on_road_volume INTEGER NOT NULL DEFAULT 0,
yesterday_volume INTEGER NOT NULL DEFAULT 0,
last_price REAL NOT NULL DEFAULT 0,
profit_rate REAL NOT NULL DEFAULT 0,
future_trade_type INTEGER,
expire_date TEXT NOT NULL DEFAULT ''
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_code ON positions (code); CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_stock_code ON positions (stock_code);
CREATE INDEX IF NOT EXISTS idx_positions_base_order_id ON positions (base_order_id);
CREATE INDEX IF NOT EXISTS idx_positions_added_order_id ON positions (added_order_id);
CREATE TABLE IF NOT EXISTS deals ( CREATE TABLE IF NOT EXISTS deals (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
sys_order_id TEXT NOT NULL UNIQUE CHECK (sys_order_id <> ''), stock_code TEXT NOT NULL,
local_order_id TEXT NOT NULL, order_sys_id TEXT NOT NULL CHECK (order_sys_id <> ''),
code TEXT NOT NULL, order_local_id TEXT NOT NULL CHECK (order_local_id <> ''),
instrument_id TEXT NOT NULL, ref INTEGER NOT NULL DEFAULT 0,
exchange_id TEXT NOT NULL, order_ref TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL, direction INTEGER NOT NULL DEFAULT 0,
offset_flag TEXT NOT NULL, offset_flag INTEGER NOT NULL CHECK (offset_flag IN (23, 24, 48, 49)),
side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')), price REAL NOT NULL CHECK (price >= 0),
status TEXT NOT NULL, volume INTEGER NOT NULL CHECK (volume > 0),
remaining_volume INTEGER NOT NULL CHECK (remaining_volume >= 0), trade_amount REAL NOT NULL CHECK (trade_amount > 0),
traded_volume INTEGER NOT NULL CHECK (traded_volume > 0), trade_date TEXT NOT NULL,
order_time INTEGER NOT NULL, trade_time TEXT NOT NULL,
insert_date TEXT NOT NULL, remark TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL, close_profit REAL NOT NULL DEFAULT 0
remark TEXT NOT NULL,
price REAL NOT NULL,
trade_price REAL NOT NULL CHECK (trade_price >= 0),
trade_amount REAL NOT NULL CHECK (trade_amount > 0)
); );
CREATE INDEX IF NOT EXISTS idx_deals_local_order_id ON deals (local_order_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_deals_order_sys_id ON deals (order_sys_id);
CREATE INDEX IF NOT EXISTS idx_deals_code_date ON deals (code, insert_date); CREATE INDEX IF NOT EXISTS idx_deals_order_ref ON deals (order_local_id);
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (insert_date, insert_time); CREATE INDEX IF NOT EXISTS idx_deals_stock_code_date ON deals (stock_code);
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (trade_date);
""" """
# SQL is assembled once; order_local_id is derived from the SDK remark property.
POSITION_FIELDS = tuple(field.name for field in fields(PositionItem))
DEAL_FIELDS = tuple(field.name for field in fields(DealItem)) + ('order_local_id',)
POSITION_UPSERT = (
f"INSERT INTO positions ({', '.join(POSITION_FIELDS)}) "
f"VALUES ({', '.join(':' + key for key in POSITION_FIELDS)}) "
"ON CONFLICT(stock_code) DO UPDATE SET "
+ ', '.join(f'{key} = excluded.{key}' for key in POSITION_FIELDS if key != 'stock_code')
)
DEAL_INSERT = (
f"INSERT INTO deals ({', '.join(DEAL_FIELDS)}) "
f"VALUES ({', '.join(':' + key for key in DEAL_FIELDS)})"
)
class OrderBook: class OrderBook:
"""Persist position snapshots and append-only executions; one writer per database.""" """Position snapshots and append-only deals. No schema migration."""
def __init__(self, path: str | Path) -> None: def __init__(self, path: str | Path) -> None:
self.path = Path(path) self.path = Path(path)
@@ -76,8 +93,8 @@ class OrderBook:
def load(self) -> None: def load(self) -> None:
with closing(self._connect()) as db, db: with closing(self._connect()) as db, db:
db.execute('BEGIN') db.execute('BEGIN')
positions = {row['code']: dict(row) for row in db.execute('SELECT * FROM positions')} positions = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM positions')}
deals = {row['sys_order_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')} deals = {row['order_sys_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')}
self.positions = positions self.positions = positions
self.deals = deals self.deals = deals
self.deals_sys_ids = set(deals) self.deals_sys_ids = set(deals)
@@ -85,86 +102,58 @@ class OrderBook:
@staticmethod @staticmethod
def _insert_deals(db: sqlite3.Connection, deals: list[dict]) -> None: def _insert_deals(db: sqlite3.Connection, deals: list[dict]) -> None:
db.executemany( db.executemany(DEAL_INSERT, deals)
"""INSERT INTO deals
(sys_order_id, local_order_id, code, instrument_id, exchange_id, name,
offset_flag, side, status, remaining_volume, traded_volume, order_time,
insert_date, insert_time, remark, price, trade_price, trade_amount)
VALUES (:sys_order_id, :local_order_id, :code, :instrument_id, :exchange_id, :name,
:offset_flag, :side, :status, :remaining_volume, :traded_volume, :order_time,
:insert_date, :insert_time, :remark, :price, :trade_price, :trade_amount)""",
deals,
)
@staticmethod @staticmethod
def deal_record(deal: DealItem) -> dict: def deal_record(deal: DealItem) -> dict:
"""API 字段转入库记录;委托价格不用于推算成交金额。""" if not deal.order_sys_id or deal.volume <= 0:
if not deal.sys_order_id or deal.traded_volume <= 0: raise ValueError('System order ID and positive volume are required')
raise ValueError('System order ID and positive traded volume are required') if not deal.local_order_id:
raise ValueError('Local order ID is required')
row = asdict(deal) row = asdict(deal)
row['order_local_id'] = deal.local_order_id
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()): if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
raise ValueError('Prices and amounts must be finite') raise ValueError('Numeric values must be finite')
amount = deal.trade_amount if deal.trade_amount > 0 else deal.trade_price * deal.traded_volume amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
if not math.isfinite(amount) or amount <= 0: if not math.isfinite(amount) or amount <= 0:
raise ValueError('Execution amount must be positive and finite') raise ValueError('Trade amount must be positive and finite')
row['trade_amount'] = amount row['trade_amount'] = amount
date = deal.insert_date or datetime.now().date().isoformat() date = deal.trade_date or datetime.now().date().isoformat()
if len(date) == 8 and date.isdigit(): if len(date) == 8 and date.isdigit():
date = f'{date[:4]}-{date[4:6]}-{date[6:]}' date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
row['insert_date'] = date row['trade_date'] = date
return row return row
def sync_deals(self, deals: list[DealItem]) -> None: def sync_deals(self, deals: list[DealItem]) -> None:
"""按系统订单号去重,批量写入已成交数据;失败时不更新缓存。"""
new_deals = [] new_deals = []
seen = self.deals_sys_ids.copy() seen = self.deals_sys_ids.copy()
for deal in deals: for deal in deals:
if deal.sys_order_id in seen: if deal.order_sys_id in seen:
continue continue
new_deals.append(self.deal_record(deal)) new_deals.append(self.deal_record(deal))
seen.add(deal.sys_order_id) seen.add(deal.order_sys_id)
if not new_deals: if not new_deals:
return return
with closing(self._connect()) as db, db: with closing(self._connect()) as db, db:
self._insert_deals(db, new_deals) self._insert_deals(db, new_deals)
self.load() self.load()
def sync_positions(self, positions: list[PositionItem]) -> None:
"""Replace the complete position snapshot, retaining IDs for existing stocks."""
self.save({item.stock_code: asdict(item) for item in positions}, list(self.deals.values()))
def save(self, items: dict, deals: list[dict]) -> None: def save(self, items: dict, deals: list[dict]) -> None:
if len(deals) < self._deal_count: if len(deals) < self._deal_count:
raise ValueError('Execution history is append-only') raise ValueError('Execution history is append-only')
new_deals = deals[self._deal_count:] new_deals = deals[self._deal_count:]
positions = [ positions = [{**asdict(PositionItem()), **item} for item in items.values()]
{ for row in [*positions, *new_deals]:
'base_order_id': '', 'base_qty': 0, 'base_cost': 0.0,
'added_order_id': '', 'added_num': 0, 'added_qty': 0, 'added_cost': 0.0,
**item,
}
for item in items.values()
]
for row in [*items.values(), *new_deals]:
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()): if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
raise ValueError('Quantities, prices and amounts must be finite') raise ValueError('Numeric values must be finite')
with closing(self._connect()) as db, db: with closing(self._connect()) as db, db:
# 更新已有证券时保留其自增 ID仅删除快照中已移除的证券。 for row in db.execute('SELECT stock_code FROM positions').fetchall():
for row in db.execute('SELECT code FROM positions').fetchall(): if row['stock_code'] not in items:
if row['code'] not in items: db.execute('DELETE FROM positions WHERE stock_code = ?', (row['stock_code'],))
db.execute('DELETE FROM positions WHERE code = ?', (row['code'],)) db.executemany(POSITION_UPSERT, positions)
db.executemany(
"""INSERT INTO positions
(code, base_order_id, base_qty, base_cost,
added_order_id, added_num, added_qty, added_cost, status)
VALUES (:code, :base_order_id, :base_qty, :base_cost,
:added_order_id, :added_num, :added_qty, :added_cost, :status)
ON CONFLICT(code) DO UPDATE SET
base_order_id = excluded.base_order_id,
base_qty = excluded.base_qty,
base_cost = excluded.base_cost,
added_order_id = excluded.added_order_id,
added_num = excluded.added_num,
added_qty = excluded.added_qty,
added_cost = excluded.added_cost,
status = excluded.status""",
positions,
)
self._insert_deals(db, new_deals) self._insert_deals(db, new_deals)
self.load() self.load()

View File

@@ -26,8 +26,7 @@ def Overview(assets, positions, account_cfg=None) -> None:
if position.volume <= 0: if position.volume <= 0:
continue continue
log.info( log.info(
"[启动] %s %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%", "[启动] %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%",
position.trade_id,
position.stock_code, position.stock_code,
position.stock_name, position.stock_name,
position.volume, position.volume,

View File

@@ -34,6 +34,7 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
from sdk import APIError, Client from sdk import APIError, Client
from libs.market import refresh_market from libs.market import refresh_market
from libs.collector import submit_trend_data
from strategy.trend.boot import StartTrend from strategy.trend.boot import StartTrend
from strategy.zt.boot import StartZT from strategy.zt.boot import StartZT
from strategy.ipo import AutoBuyIpo from strategy.ipo import AutoBuyIpo
@@ -109,6 +110,7 @@ def wait_for_any_key() -> None:
def main() -> int: def main() -> int:
scheduler = None
try: try:
if not require_windows(): if not require_windows():
logging.error("本程序仅支持 Windows 环境运行") logging.error("本程序仅支持 Windows 环境运行")
@@ -143,18 +145,29 @@ def main() -> int:
replace_existing=True, replace_existing=True,
next_run_time=datetime.now(), next_run_time=datetime.now(),
) )
scheduler.add_job(
submit_trend_data,
trigger="interval",
minutes=5,
id="trend_collector",
replace_existing=True,
)
logging.info("趋势策略数据提交任务已注册每5分钟读取缓存提交")
scheduler.start() scheduler.start()
logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00") logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00")
logging.info("大盘信号后台刷新已启动:每分钟一次") logging.info("大盘信号后台刷新已启动:每分钟一次")
STRATEGIES[config.account_config.strategy].start_strategy() STRATEGIES[config.account_config.strategy].start_strategy()
logging.info("%s 策略启动成功",config.account_config.strateg) logging.info("%s 策略已结束", config.account_config.strategy)
return 0 return 0
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e: except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e:
print(f"启动失败: {e}", file=sys.stderr, flush=True) print(f"启动失败: {e}", file=sys.stderr, flush=True)
traceback.print_exception(type(e), e, e.__traceback__) traceback.print_exception(type(e), e, e.__traceback__)
wait_for_any_key() wait_for_any_key()
return 1 return 1
finally:
if scheduler is not None and scheduler.running:
scheduler.shutdown(wait=True)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -13,118 +13,75 @@ def _number(value: Any, kind: type = float) -> Any:
@dataclass(slots=True) @dataclass(slots=True)
class OrderItem: class OrderItem:
"""由 QMT 委托明细解析得到的标准订单记录。""" """Fields match format_orders() exactly."""
id: str stock_code: str = ""
code: str order_sys_id: str = ""
side: str ref: int = 0
remark: str order_ref: str = ""
status: str direction: int = 0
created_at: datetime | None offset_flag: int = 0
volume: int limit_price: float = 0.0
local_order_id: str = "" volume_total_original: int = 0
traded_volume: int = 0 volume_traded: int = 0
remaining_volume: int = 0 volume_total: int = 0
exchange_id: str = "" traded_price: float = 0.0
name: str = ""
price: float = 0.0
trade_price: float = 0.0
trade_amount: float = 0.0 trade_amount: float = 0.0
insert_date: str = ""
insert_time: str = ""
remark: str = ""
order_status: int = 0
@classmethod @property
def from_trade_detail(cls, data: dict[str, Any]) -> "OrderItem": def side(self) -> str:
"""从 TradeDetailData 的 QMT 原始字段创建订单。""" return _side(self.offset_flag)
instrument_id = str(data.get("m_strInstrumentID") or "")
exchange_id = str(data.get("m_strExchangeID") or "") @property
code = ( def local_order_id(self) -> str:
f"{instrument_id}.{exchange_id}" return self.remark.split("|", 1)[0]
if instrument_id and exchange_id
else instrument_id @property
) def created_at(self) -> datetime | None:
remaining_volume = _number(data.get("m_nVolumeTotal"), int) return _parse_datetime(self.insert_date, self.insert_time)
traded_volume = _number(data.get("m_nVolumeTraded"), int)
remark = str(data.get("m_strRemark") or "")
return cls(
id=str(data.get("m_strOrderSysID") or ""),
code=code,
side={"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(
str(data.get("m_nOffsetFlag")), ""
),
remark=remark,
status=str(data.get("m_nOrderStatus") or ""),
created_at=_trade_datetime(data),
volume=remaining_volume + traded_volume,
local_order_id=remark.split("|", 1)[0] if remark else "",
traded_volume=traded_volume,
remaining_volume=remaining_volume,
exchange_id=exchange_id,
name=str(data.get("m_strInstrumentName") or ""),
price=_number(data.get("m_dPrice")),
trade_price=_number(data.get("m_dTradePrice")),
trade_amount=_number(data.get("m_dTradeAmount")),
)
@dataclass(slots=True) @dataclass(slots=True)
class DealItem: class DealItem:
"""Execution data from the API's fixed order-detail fields.""" """Fields match format_deals() exactly."""
sys_order_id: str = "" stock_code: str = ""
local_order_id: str = "" order_sys_id: str = ""
code: str = "" ref: int = 0
instrument_id: str = "" order_ref: str = ""
exchange_id: str = "" direction: int = 0
name: str = "" offset_flag: int = 0
offset_flag: str = ""
side: str = ""
status: str = ""
remaining_volume: int = 0
traded_volume: int = 0
order_time: int = 0
insert_date: str = ""
insert_time: str = ""
remark: str = ""
price: float = 0.0 price: float = 0.0
trade_price: float = 0.0 volume: int = 0
trade_amount: float = 0.0 trade_amount: float = 0.0
trade_date: str = ""
trade_time: str = ""
remark: str = ""
close_profit: float = 0.0
@classmethod @property
def from_trade_detail(cls, data: dict[str, Any]) -> "DealItem": def side(self) -> str:
instrument_id = str(data.get("m_strInstrumentID") or "") return _side(self.offset_flag)
exchange_id = str(data.get("m_strExchangeID") or "")
remark = str(data.get("m_strRemark") or "") @property
offset_flag = str(data.get("m_nOffsetFlag") or "") def local_order_id(self) -> str:
return cls( return self.remark.split("|", 1)[0]
sys_order_id=str(data.get("m_strOrderSysID") or ""),
local_order_id=remark.split("|", 1)[0] if remark else "",
code=f"{instrument_id}.{exchange_id}" if instrument_id and exchange_id else instrument_id,
instrument_id=instrument_id,
exchange_id=exchange_id,
name=str(data.get("m_strInstrumentName") or ""),
offset_flag=offset_flag,
side={"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(offset_flag, ""),
status=str(data.get("m_nOrderStatus") or ""),
remaining_volume=_number(data.get("m_nVolumeTotal"), int),
traded_volume=_number(data.get("m_nVolumeTraded"), int),
order_time=_number(data.get("m_nOrderTime"), int),
insert_date=str(data.get("m_strInsertDate") or ""),
insert_time=str(data.get("m_strInsertTime") or ""),
remark=remark,
price=_number(data.get("m_dPrice")),
trade_price=_number(data.get("m_dTradePrice")),
trade_amount=_number(data.get("m_dTradeAmount")),
)
@dataclass(slots=True) @dataclass(slots=True)
class PositionItem: class PositionItem:
"""Fields match format_holding() exactly."""
stock_code: str = "" stock_code: str = ""
stock_name: str = "" stock_name: str = ""
trade_id: str = ""
direction: Any = None direction: Any = None
volume: int = 0 volume: int = 0
open_cost: float = 0.0
open_price: float = 0.0 open_price: float = 0.0
open_cost: float = 0.0
float_profit: float = 0.0 float_profit: float = 0.0
market_value: float = 0.0 market_value: float = 0.0
stock_holder: str = "" stock_holder: str = ""
@@ -137,67 +94,14 @@ class PositionItem:
future_trade_type: Any = None future_trade_type: Any = None
expire_date: str = "" expire_date: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem":
return cls(
stock_code=str(data.get("StockCode") or code),
stock_name=str(data.get("StockName") or ""),
trade_id=str(data.get("TradeID") or ""),
open_cost=_number(data.get("OpenCost")),
direction=data.get("Direction"),
volume=_number(data.get("Volume"), int),
open_price=_number(data.get("OpenPrice")),
float_profit=_number(data.get("FloatProfit")),
market_value=_number(data.get("MarketValue")),
stock_holder=str(data.get("StockHolder") or ""),
frozen_volume=_number(data.get("FrozenVolume"), int),
can_use_volume=_number(data.get("CanUseVolume"), int),
on_road_volume=_number(data.get("OnRoadVolume"), int),
yesterday_volume=_number(data.get("YesterdayVolume"), int),
last_price=_number(data.get("LastPrice")),
profit_rate=_number(data.get("ProfitRate")),
future_trade_type=data.get("FutureTradeType"),
expire_date=str(data.get("ExpireDate") or ""),
)
@classmethod
def from_trade_detail(cls, data: dict[str, Any]) -> "PositionItem":
"""从 TradeDetailData/Holding 的 QMT 原始字段创建持仓。"""
return cls(
stock_code=str(data.get("StockCode") or ""),
stock_name=str(data.get("StockName") or ""),
trade_id=str(data.get("TradeID") or ""),
direction=data.get("Direction"),
volume=_number(data.get("Volume"), int),
open_cost=_number(data.get("OpenCost")),
open_price=_number(data.get("OpenPrice")),
float_profit=_number(data.get("FloatProfit")),
market_value=_number(data.get("MarketValue")),
stock_holder=str(data.get("StockHolder") or ""),
frozen_volume=_number(data.get("FrozenVolume"), int),
can_use_volume=_number(data.get("CanUseVolume"), int),
on_road_volume=_number(data.get("OnRoadVolume"), int),
yesterday_volume=_number(data.get("YesterdayVolume"), int),
last_price=_number(data.get("LastPrice")),
profit_rate=_number(data.get("ProfitRate")),
future_trade_type=data.get("FutureTradeType"),
expire_date=str(data.get("ExpireDate") or ""),
)
@dataclass(slots=True) @dataclass(slots=True)
class Assets: class Assets:
"""Fields match format_assets() exactly."""
total: float = 0.0 total: float = 0.0
available: float = 0.0 available: float = 0.0
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Assets":
"""兼容新版 QMT 原始资金字段及旧版简化字段。"""
return cls(
total=_number(data.get("m_dBalance", data.get("total"))),
available=_number(data.get("m_dAvailable", data.get("available"))),
)
@dataclass(slots=True) @dataclass(slots=True)
class Portfolio: class Portfolio:
@@ -206,17 +110,14 @@ class Portfolio:
orders: list[OrderItem] orders: list[OrderItem]
def _trade_datetime(data: dict[str, Any]) -> datetime | None: def _side(offset_flag: int) -> str:
return _parse_datetime( return {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(str(offset_flag), "")
str(data.get("m_strInsertDate") or ""),
str(data.get("m_strInsertTime") or ""),
)
def _parse_datetime(date: str, clock: str) -> datetime | None: def _parse_datetime(date: str, clock: str) -> datetime | None:
clock = clock.replace(":", "").zfill(6) clock = clock.replace(":", "").zfill(6)
try: try:
return datetime.strptime(date + clock, "%Y%m%d%H%M%S") return datetime.strptime(date.replace("-", "") + clock, "%Y%m%d%H%M%S")
except ValueError: except ValueError:
return None return None

View File

@@ -17,33 +17,33 @@ class PortfolioMixin:
def portfolio(self) -> Portfolio: def portfolio(self) -> Portfolio:
data = self._get_json("/api/portfolio") or {} data = self._get_json("/api/portfolio") or {}
positions = { positions = {
code: PositionItem.from_dict(value, code) code: PositionItem(**value)
for code, value in data.get("positions", {}).items() for code, value in data.get("positions", {}).items()
} }
return Portfolio( return Portfolio(
assets=Assets.from_dict(data.get("assets", {})), assets=Assets(**data.get("assets", {})),
positions=positions, positions=positions,
orders=[OrderItem.from_trade_detail(row) for row in data.get("orders", [])], orders=[OrderItem(**row) for row in data.get("orders", [])],
) )
def positions(self) -> tuple[list[str], list[PositionItem]]: def positions(self) -> tuple[list[str], list[PositionItem]]:
data = self._get_json("/api/portfolio/positions") or {} data = self._get_json("/api/portfolio/positions") or {}
positions = [ positions = [
PositionItem.from_dict(value, code) PositionItem(**value)
for code, value in data.get("data", {}).items() for value in data.values()
] ]
return [item.stock_code for item in positions], positions return list(data), positions
def assets(self) -> Assets: def assets(self) -> Assets:
return Assets.from_dict(self._get_json("/api/portfolio/assets") or {}) return Assets(**(self._get_json("/api/portfolio/assets") or {}))
def orders(self) -> list[OrderItem]: def orders(self) -> list[OrderItem]:
data = self._get_json("/api/portfolio/order") or [] data = self._get_json("/api/portfolio/order") or []
return [OrderItem.from_trade_detail(row) for row in data] return [OrderItem(**row) for row in data]
def deals(self) -> list[DealItem]: def deals(self) -> list[DealItem]:
data = self._get_json("/api/portfolio/deal") or [] data = self._get_json("/api/portfolio/deal") or []
return [DealItem.from_trade_detail(row) for row in data] return [DealItem(**row) for row in data]
def trade_detail_data(self, datatype: str) -> Any: def trade_detail_data(self, datatype: str) -> Any:
datatype = str(datatype).strip().lower() datatype = str(datatype).strip().lower()

View File

@@ -8,15 +8,16 @@ from __future__ import annotations
import time import time
import logging as log import logging as log
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from copy import deepcopy
from datetime import datetime from datetime import datetime
from threading import Lock
import config import config
from libs.calc import trading_time from libs.calc import trading_time
from libs.market import market_allow_open from libs.market import market_allow_open
from libs.overview import Overview from libs.overview import Overview
from libs.signal import init_signals, SignalItem from libs.signal import init_signals, SignalItem
from libs.collector import collector_push from sdk import Assets, Client, PositionItem
from sdk import Client
from libs.grid_take_profit import GridTrailingTracker from libs.grid_take_profit import GridTrailingTracker
from libs.order import OrderBook from libs.order import OrderBook
from libs.watch import DipWatch from libs.watch import DipWatch
@@ -24,6 +25,23 @@ from libs.runtime import Runtime
from .open import open_signal from .open import open_signal
from .positions import manage_positions from .positions import manage_positions
_collector_lock = Lock()
_collector_snapshot: tuple[str, Assets, list[PositionItem]] | None = None
def _cache_portfolio(account_id: str, assets: Assets, positions: list[PositionItem]) -> None:
"""整体替换最新快照,策略线程不执行序列化和网络上报。"""
global _collector_snapshot
with _collector_lock:
_collector_snapshot = (account_id, assets, positions)
def get_collector_snapshot() -> tuple[str, Assets, list[PositionItem]] | None:
"""供 scheduler 读取;复制在锁外执行,不阻塞下一轮缓存更新。"""
with _collector_lock:
snapshot = _collector_snapshot
return deepcopy(snapshot)
def StartTrend() -> None: def StartTrend() -> None:
"""初始化趋势策略,并以 30 秒间隔持续执行。""" """初始化趋势策略,并以 30 秒间隔持续执行。"""
@@ -37,6 +55,7 @@ def StartTrend() -> None:
portfolio = client.portfolio() portfolio = client.portfolio()
assets = portfolio.assets assets = portfolio.assets
positions = list(portfolio.positions.values()) positions = list(portfolio.positions.values())
_cache_portfolio(config.account_config.account_id, assets, positions)
order_book = OrderBook("trend") order_book = OrderBook("trend")
order_book.refresh(client, portfolio.orders) order_book.refresh(client, portfolio.orders)
@@ -51,7 +70,7 @@ def StartTrend() -> None:
len(signals), len(signals),
len(positions), len(positions),
) )
executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="trend") executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="trend")
run = Runtime( run = Runtime(
client=client, client=client,
global_cfg=config.global_config, global_cfg=config.global_config,
@@ -116,22 +135,13 @@ def RunOnce(run: Runtime, signals: list[SignalItem]) -> None:
assets = portfolio.assets assets = portfolio.assets
position_codes = list(portfolio.positions) position_codes = list(portfolio.positions)
positions = list(portfolio.positions.values()) positions = list(portfolio.positions.values())
_cache_portfolio(run.account_cfg.account_id, assets, positions)
run.orders.refresh(run.client, portfolio.orders) run.orders.refresh(run.client, portfolio.orders)
except Exception: except Exception:
log.exception("[Portfolio] 刷新账户快照失败") log.exception("[Portfolio] 刷新账户快照失败")
return return
futures: list[tuple[str, Future]] = [ futures: list[tuple[str, Future]] = []
(
"数据提交",
run.executor.submit(
collector_push,
run.account_cfg.account_id,
assets,
positions,
),
)
]
# 2. 验证可用资金;低于资金安全线时禁止开新仓。 # 2. 验证可用资金;低于资金安全线时禁止开新仓。
allow_open_by_cash = ( allow_open_by_cash = (

View File

@@ -60,9 +60,9 @@ class TState:
@classmethod @classmethod
def _apply_t_deal(cls, item: TStateItem, deal: dict) -> None: def _apply_t_deal(cls, item: TStateItem, deal: dict) -> None:
"""实时入账与重启恢复共用同一套做 T 轮次计算。""" """实时入账与重启恢复共用同一套做 T 轮次计算。"""
cls._reset(item, deal['insert_date']) cls._reset(item, deal['trade_date'])
qty, amount = deal['traded_volume'], deal['trade_amount'] qty, amount = deal['volume'], deal['trade_amount']
if deal['side'] == 'SELL': if str(deal['offset_flag']) in ('24', '49'):
total = item.sell_qty + qty total = item.sell_qty + qty
item.sell_price = (item.sell_qty * item.sell_price + amount) / total item.sell_price = (item.sell_qty * item.sell_price + amount) / total
item.sell_qty = total item.sell_qty = total
@@ -72,32 +72,32 @@ class TState:
item.buy_cost = (item.buy_qty * item.buy_cost + amount) / total item.buy_cost = (item.buy_qty * item.buy_cost + amount) / total
item.buy_qty = total item.buy_qty = total
item.phase = DONE if total >= item.sell_qty else SOLD item.phase = DONE if total >= item.sell_qty else SOLD
item.trade_date = deal['insert_date'] item.trade_date = deal['trade_date']
def reconcile( def reconcile(
self, positions: list[PositionItem], deals: list[DealItem] self, positions: list[PositionItem], deals: list[DealItem]
) -> None: ) -> None:
"""Deduplicate each fill; partial fills do not wait for order completion.""" """Deduplicate each fill; partial fills do not wait for order completion."""
today = datetime.now().date().isoformat() today = datetime.now().date().isoformat()
seen = {row['sys_order_id'] for row in self.deals} seen = {row['order_sys_id'] for row in self.deals}
rows = [] rows = []
for deal in deals: for deal in deals:
if not self._is_zt_deal(deal) or deal.sys_order_id in seen: if not self._is_zt_deal(deal) or deal.order_sys_id in seen:
continue continue
try: try:
row = self._store.deal_record(deal) row = self._store.deal_record(deal)
except ValueError: except ValueError:
continue continue
rows.append(row) rows.append(row)
seen.add(deal.sys_order_id) seen.add(deal.order_sys_id)
rows.sort(key=lambda r: (r['insert_date'], r['insert_time'])) rows.sort(key=lambda r: (r['trade_date'], r['trade_time']))
modified = False modified = False
try: try:
# Snapshot includes these fills: subtract their net quantity before replay. # Snapshot includes these fills: subtract their net quantity before replay.
net = {} net = {}
for row in rows: for row in rows:
net[row['code']] = net.get(row['code'], 0) + ( net[row['stock_code']] = net.get(row['stock_code'], 0) + (
row['traded_volume'] if row['side'] == 'BUY' else -row['traded_volume'] row['volume'] if str(row['offset_flag']) in ('23', '48') else -row['volume']
) )
for position in positions: for position in positions:
code = position.stock_code code = position.stock_code
@@ -115,14 +115,14 @@ class TState:
self.items[code] = TStateItem(code, -delta) self.items[code] = TStateItem(code, -delta)
for row in rows: for row in rows:
item = self.items.setdefault(row['code'], TStateItem(row['code'])) item = self.items.setdefault(row['stock_code'], TStateItem(row['stock_code']))
self._reset(item, row['insert_date']) self._reset(item, row['trade_date'])
qty, amount = row['traded_volume'], row['trade_amount'] qty, amount = row['volume'], row['trade_amount']
if row['local_order_id'].startswith('zt-base-'): if row['order_local_id'].startswith('zt-base-'):
total = item.base_qty + qty total = item.base_qty + qty
item.base_cost = (item.base_qty * item.base_cost + amount) / total item.base_cost = (item.base_qty * item.base_cost + amount) / total
item.base_qty = total item.base_qty = total
item.base_order_id = row['local_order_id'] item.base_order_id = row['order_local_id']
else: else:
self._apply_t_deal(item, row) self._apply_t_deal(item, row)
self.deals.append(row) self.deals.append(row)
@@ -141,15 +141,10 @@ class TState:
self._store.save( self._store.save(
{ {
code: { code: {
'code': item.code, 'stock_code': item.code,
'base_order_id': item.base_order_id, 'volume': item.base_qty,
'base_qty': item.base_qty, 'open_price': item.base_cost,
'base_cost': item.base_cost, 'open_cost': item.base_qty * item.base_cost,
'added_order_id': item.added_order_id,
'added_num': item.added_num,
'added_qty': item.added_qty,
'added_cost': item.added_cost,
'status': item.phase,
} }
for code, item in self.items.items() for code, item in self.items.items()
}, },
@@ -163,19 +158,21 @@ class TState:
self._store.load() self._store.load()
self.items = {} self.items = {}
for code, position in self._store.positions.items(): for code, position in self._store.positions.items():
position = dict(position) self.items[code] = TStateItem(code, position['volume'], position['open_price'], id=position['id'])
position['phase'] = position.pop('status')
self.items[code] = TStateItem(**position)
self.deals = [ self.deals = [
{key: value for key, value in deal.items() if key != 'id'} {key: value for key, value in deal.items() if key != 'id'}
for deal in self._store.deals.values() for deal in self._store.deals.values()
] ]
# 轮次明细不占用持仓表字段,从已保存的逐笔成交重建。 # 轮次明细不占用持仓表字段,从已保存的逐笔成交重建。
for deal in self.deals: for deal in self.deals:
if not deal['local_order_id'].startswith('zt-base-') and deal['code'] in self.items: item = self.items.get(deal['stock_code'])
self._apply_t_deal(self.items[deal['code']], deal) if item is None:
for code, item in self.items.items(): continue
if self._store.positions[code]['status'] == READY: local_order_id = deal['order_local_id']
item.phase, item.trade_date = READY, '' if local_order_id.startswith('zt-base-'):
item.sell_qty = item.buy_qty = 0 item.base_order_id = local_order_id
item.sell_price = item.buy_cost = 0.0 else:
self._apply_t_deal(item, deal)
today = datetime.now().date().isoformat()
for item in self.items.values():
self._reset(item, today)

View File

@@ -1,75 +1,89 @@
import sqlite3 import ast
import tempfile import tempfile
import unittest import unittest
from contextlib import closing from dataclasses import asdict, fields
from dataclasses import asdict from datetime import datetime
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from libs.order import OrderBook as ActiveOrders
from libs.orderbook import OrderBook from libs.orderbook import OrderBook
from sdk.models import DealItem from sdk.models import Assets, DealItem, OrderItem, PositionItem
from sdk.portfolio import PortfolioMixin from sdk.portfolio import PortfolioMixin
class DealModelTests(unittest.TestCase): class ApiModelTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.raw = { source = Path(__file__).resolve().parents[2] / 'api' / 'qmt_rest_new.py'
'm_strOrderSysID': 'sys-123', names = {'format_assets', 'format_holding', 'format_orders', 'format_deals'}
'm_strInstrumentID': '600000', nodes = [n for n in ast.parse(source.read_text(encoding='utf-8')).body
'm_strExchangeID': 'SH', if isinstance(n, ast.FunctionDef) and n.name in names]
'm_strInstrumentName': 'Test stock', ns = {'HTTPError': RuntimeError}
'm_nOffsetFlag': '24', exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), ns)
'm_nOrderStatus': '56', attrs = {n.attr: '' if n.attr.startswith('m_str') else 0
'm_nVolumeTotal': '0', for node in nodes for n in ast.walk(node)
'm_nVolumeTraded': '200', if isinstance(n, ast.Attribute) and n.attr.startswith('m_')}
'm_nOrderTime': '101530', attrs.update(m_strInstrumentID='600000', m_strExchangeID='SH',
'm_strInsertDate': '20260907', m_strOrderSysID='sys1', m_strRemark='trend-BUY-1|trend',
'm_strInsertTime': '10:15:30', m_nOffsetFlag=23, m_nOrderStatus=56, m_nVolume=100,
'm_strRemark': 'zt-t-sell-local1|zt', m_nVolumeTraded=100, m_nVolumeTotalOriginal=100,
'm_dPrice': '15.8', m_dPrice=10.0, m_dTradeAmount=1000.0, m_dBalance=2000.0,
'm_dTradePrice': '15.6', m_dAvailable=1000.0, m_strInsertDate='20260907',
'm_dTradeAmount': '3120', m_strInsertTime='100000', m_strTradeDate='20260907', m_strTradeTime='100000')
} obj = SimpleNamespace(**attrs)
self.assets = ns['format_assets']([obj])
self.positions = ns['format_holding']([obj])
self.orders = ns['format_orders']([obj])
self.deals = ns['format_deals']([obj])
self.client = PortfolioMixin()
self.client._get_json = {
'/api/portfolio/assets': self.assets, '/api/portfolio/positions': self.positions,
'/api/portfolio/order': self.orders, '/api/portfolio/deal': self.deals,
'/api/portfolio': {'assets': self.assets, 'positions': self.positions, 'orders': self.orders},
}.__getitem__
def test_all_api_fields_are_parsed(self): def test_models_exactly_match_api_keys_and_values(self):
self.assertEqual(asdict(DealItem.from_trade_detail(self.raw)), { for model, row in ((Assets, self.assets), (PositionItem, self.positions['600000.SH']),
'sys_order_id': 'sys-123', 'local_order_id': 'zt-t-sell-local1', (OrderItem, self.orders[0]), (DealItem, self.deals[0])):
'code': '600000.SH', 'instrument_id': '600000', 'exchange_id': 'SH', self.assertEqual({field.name for field in fields(model)}, set(row))
'name': 'Test stock', 'offset_flag': '24', 'side': 'SELL', 'status': '56', self.assertEqual(asdict(model(**row)), row)
'remaining_volume': 0, 'traded_volume': 200, 'order_time': 101530,
'insert_date': '20260907', 'insert_time': '10:15:30',
'remark': 'zt-t-sell-local1|zt', 'price': 15.8,
'trade_price': 15.6, 'trade_amount': 3120.0,
})
def test_api_deals_response_returns_deal_items(self): def test_all_endpoints(self):
client = PortfolioMixin() self.assertEqual(asdict(self.client.assets()), self.assets)
for response in ({'deals': [self.raw]}, [self.raw]): codes, positions = self.client.positions()
with self.subTest(response_type=type(response).__name__): self.assertEqual(codes, ['600000.SH'])
client._get_json = lambda path: response self.assertEqual(asdict(positions[0]), self.positions[codes[0]])
deals = client.deals() self.assertEqual(asdict(self.client.orders()[0]), self.orders[0])
self.assertIsInstance(deals[0], DealItem) self.assertEqual(asdict(self.client.deals()[0]), self.deals[0])
self.assertEqual(deals[0].sys_order_id, 'sys-123') portfolio = self.client.portfolio()
self.assertEqual(deals[0].traded_volume, 200) self.assertEqual(asdict(portfolio.positions[codes[0]]), self.positions[codes[0]])
self.assertEqual(asdict(portfolio.orders[0]), self.orders[0])
def test_model_matches_sql_columns_and_restart(self): def test_derived_properties_and_order_cache(self):
deal = DealItem.from_trade_detail(self.raw) order = self.client.orders()[0]
self.assertEqual(order.side, 'BUY')
self.assertEqual(order.local_order_id, 'trend-BUY-1')
self.assertEqual(order.created_at, datetime(2026, 9, 7, 10))
order.order_status = 50
order.insert_date = '20000101'
client = Mock()
book = ActiveOrders('trend')
book.refresh(client, [order])
client.cancel_by_id.assert_called_once_with('sys1')
self.assertTrue(book.busy('600000.SH', 'BUY'))
def test_storage_and_price_fallback(self):
deal = self.client.deals()[0]
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'state.db' path = Path(tmp) / 'state.db'
book = OrderBook(path) book = OrderBook(path)
book.sync_deals([deal]) book.sync_deals([deal])
with closing(sqlite3.connect(path)) as db: loaded = OrderBook(path).deals['sys1']
columns = {row[1] for row in db.execute('PRAGMA table_info(deals)')} self.assertEqual(loaded['volume'], deal.volume)
self.assertEqual(columns, {'id', *asdict(deal)}) self.assertEqual(loaded['trade_date'], '2026-09-07')
loaded = OrderBook(path).deals['sys-123'] deal.trade_amount = 0
expected = asdict(deal) self.assertEqual(OrderBook.deal_record(deal)['trade_amount'], 1000)
expected['insert_date'] = '2026-09-07' deal.price = 0
self.assertEqual({key: value for key, value in loaded.items() if key != 'id'}, expected)
def test_missing_amount_uses_execution_price_not_order_price(self):
deal = DealItem.from_trade_detail({**self.raw, 'm_dTradeAmount': '0'})
row = OrderBook.deal_record(deal)
self.assertEqual(row['trade_amount'], 3120)
deal.trade_price = 0
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
OrderBook.deal_record(deal) OrderBook.deal_record(deal)

View File

@@ -2,6 +2,7 @@ import sqlite3
import tempfile import tempfile
import unittest import unittest
from contextlib import closing from contextlib import closing
from dataclasses import asdict, fields
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from unittest.mock import patch from unittest.mock import patch
@@ -23,18 +24,12 @@ class OrderBookTests(unittest.TestCase):
def deal(self, kind, sys_order_id, qty, price, date='2026-09-01'): def deal(self, kind, sys_order_id, qty, price, date='2026-09-01'):
prefix = {'base': 'zt-base-', 'sell': 'zt-t-sell-', 'buy': 'zt-t-buy-'}[kind] prefix = {'base': 'zt-base-', 'sell': 'zt-t-sell-', 'buy': 'zt-t-buy-'}[kind]
return DealItem.from_trade_detail({ return DealItem(
'm_strOrderSysID': sys_order_id, order_sys_id=sys_order_id, stock_code='600000.SH',
'm_strInstrumentID': '600000', 'm_strExchangeID': 'SH', offset_flag=24 if kind == 'sell' else 23,
'm_strInstrumentName': 'Test stock', volume=qty, price=price, trade_amount=qty * price,
'm_nOffsetFlag': '24' if kind == 'sell' else '23', trade_date=date, trade_time='10:00:00', remark=prefix + 'order1|zt',
'm_nOrderStatus': '56', 'm_nVolumeTotal': '0', )
'm_nVolumeTraded': str(qty), 'm_nOrderTime': '100000',
'm_strInsertDate': date, 'm_strInsertTime': '10:00:00',
'm_strRemark': prefix + 'order1|zt',
'm_dPrice': str(price + 1), 'm_dTradePrice': str(price),
'm_dTradeAmount': str(qty * price),
})
def test_partial_fills_restart_dedup_and_daily_cycle(self): def test_partial_fills_restart_dedup_and_daily_cycle(self):
state = TState(self.path) state = TState(self.path)
@@ -83,10 +78,12 @@ class OrderBookTests(unittest.TestCase):
insert.assert_called_once() insert.assert_called_once()
self.assertEqual(len(insert.call_args.args[1]), 2) self.assertEqual(len(insert.call_args.args[1]), 2)
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'}) self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
self.assertEqual(book.deals['d1']['insert_date'], '2026-09-01') self.assertEqual(book.deals['d1']['trade_date'], '2026-09-01')
self.assertEqual(book.deals['d2']['traded_volume'], 60) self.assertEqual(book.deals['d2']['volume'], 60)
self.assertEqual(book.deals['d2']['order_local_id'], 'zt-base-order1')
book = OrderBook(self.path) book = OrderBook(self.path)
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'}) self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
self.assertEqual(book.deals['d1']['order_local_id'], 'zt-base-order1')
with patch.object(book, '_insert_deals') as insert: with patch.object(book, '_insert_deals') as insert:
book.sync_deals([first, second]) book.sync_deals([first, second])
book.sync_deals([]) book.sync_deals([])
@@ -97,25 +94,25 @@ class OrderBookTests(unittest.TestCase):
book = OrderBook(self.path) book = OrderBook(self.path)
first = self.deal('base', 'd1', 100, 10) first = self.deal('base', 'd1', 100, 10)
invalid = self.deal('base', 'd2', 100, 10) invalid = self.deal('base', 'd2', 100, 10)
invalid.side = 'INVALID' invalid.offset_flag = -1
with self.assertRaises(sqlite3.IntegrityError): with self.assertRaises(sqlite3.IntegrityError):
book.sync_deals([first, invalid]) book.sync_deals([first, invalid])
self.assertEqual(book.deals, {}) self.assertEqual(book.deals, {})
self.assertEqual(book.deals_sys_ids, set()) self.assertEqual(book.deals_sys_ids, set())
self.assertEqual(OrderBook(self.path).deals, {}) self.assertEqual(OrderBook(self.path).deals, {})
invalid.side = 'BUY' invalid.offset_flag = 23
book.sync_deals([first, invalid]) book.sync_deals([first, invalid])
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'}) self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
def test_load_refreshes_all_caches(self): def test_load_refreshes_all_caches(self):
book = OrderBook(self.path) book = OrderBook(self.path)
writer = OrderBook(self.path) writer = OrderBook(self.path)
writer.save({'600000.SH': {'code': '600000.SH', 'status': READY}}, []) writer.sync_positions([PositionItem(stock_code='600000.SH', volume=100)])
writer.sync_deals([self.deal('base', 'd1', 100, 10)]) writer.sync_deals([self.deal('base', 'd1', 100, 10)])
book.load() book.load()
self.assertEqual(book.positions['600000.SH']['status'], READY) self.assertEqual(book.positions['600000.SH']['volume'], 100)
self.assertEqual(book.deals_sys_ids, {'d1'}) self.assertEqual(book.deals_sys_ids, {'d1'})
self.assertEqual(book.deals['d1']['local_order_id'], 'zt-base-order1') self.assertEqual(book.deals['d1']['remark'], 'zt-base-order1|zt')
def test_first_start_after_full_sale_keeps_buyback_quantity(self): def test_first_start_after_full_sale_keeps_buyback_quantity(self):
state = TState(self.path) state = TState(self.path)
@@ -148,12 +145,17 @@ class OrderBookTests(unittest.TestCase):
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
self.assertEqual(tables, {'positions', 'deals', 'sqlite_sequence'}) self.assertEqual(tables, {'positions', 'deals', 'sqlite_sequence'})
columns = {row[1] for row in db.execute('PRAGMA table_info(deals)')} columns = {row[1] for row in db.execute('PRAGMA table_info(deals)')}
self.assertFalse({'kind', 'confirmed_date', 'deal_ids', 'deal_id', 'order_id', 'qty', 'filled_qty', 'filled_cost', 'amount', 'trade_date', 'trade_time'} & columns) self.assertEqual(columns, {'id', 'order_local_id', *(field.name for field in fields(DealItem))})
self.assertTrue({'sys_order_id', 'local_order_id'} <= columns)
indexes = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='index'")} indexes = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='index'")}
self.assertTrue({'idx_positions_code', 'idx_positions_base_order_id', self.assertTrue({'idx_positions_stock_code', 'idx_deals_order_sys_id',
'idx_positions_added_order_id', 'idx_deals_local_order_id', 'idx_deals_order_ref', 'idx_deals_stock_code_date', 'idx_deals_date_time'} <= indexes)
'idx_deals_code_date', 'idx_deals_date_time'} <= indexes) for index, expected in (
('idx_deals_order_sys_id', ['order_sys_id']),
('idx_deals_order_ref', ['order_local_id']),
('idx_deals_stock_code_date', ['stock_code']),
('idx_deals_date_time', ['trade_date']),
):
self.assertEqual([row[2] for row in db.execute(f'PRAGMA index_info({index})')], expected)
self.assertNotIn('kind', state.deals[0]) self.assertNotIn('kind', state.deals[0])
self.assertEqual(TState(self.path).deals, state.deals) self.assertEqual(TState(self.path).deals, state.deals)
state.deals.append(dict(state.deals[0])) state.deals.append(dict(state.deals[0]))
@@ -168,29 +170,23 @@ class OrderBookTests(unittest.TestCase):
def test_position_columns_defaults_indexes_and_stable_id(self): def test_position_columns_defaults_indexes_and_stable_id(self):
store = OrderBook(self.path) store = OrderBook(self.path)
with closing(sqlite3.connect(self.path)) as db: with closing(sqlite3.connect(self.path)) as db:
columns = [row[1] for row in db.execute('PRAGMA table_info(positions)')] columns = {row[1] for row in db.execute('PRAGMA table_info(positions)')}
self.assertEqual(columns, [ self.assertEqual(columns, {'id', *(field.name for field in fields(PositionItem))})
'id', 'code', 'base_order_id', 'base_qty', 'base_cost',
'added_order_id', 'added_num', 'added_qty', 'added_cost', 'status',
])
indexes = {row[1] for row in db.execute('PRAGMA index_list(positions)')} indexes = {row[1] for row in db.execute('PRAGMA index_list(positions)')}
self.assertEqual(indexes, { self.assertEqual(indexes, {'idx_positions_stock_code'})
'idx_positions_code', 'idx_positions_base_order_id', 'idx_positions_added_order_id', position = PositionItem(stock_code='600000.SH', volume=100, open_price=10,
}) stock_name='stock', can_use_volume=100, float_profit=-2.5)
store.save({'600000.SH': {'code': '600000.SH', 'status': READY}}, []) store.sync_positions([position])
position = store.positions['600000.SH'] saved = store.positions[position.stock_code]
first_id = position['id'] first_id = saved['id']
self.assertGreater(first_id, 0) self.assertEqual({k: v for k, v in saved.items() if k != 'id'}, asdict(position))
self.assertEqual(position['base_order_id'], '') position.volume = 200
self.assertEqual(position['base_qty'], 0) store.sync_positions([position])
self.assertEqual(position['added_cost'], 0.0) self.assertEqual(store.positions[position.stock_code]['id'], first_id)
position.update(base_order_id='base1', base_qty=200, base_cost=10.5, self.assertEqual(store.positions[position.stock_code]['volume'], 200)
added_order_id='add1', added_num=1, added_qty=100, added_cost=9.0, store.sync_positions([])
status='ACTIVE') self.assertEqual(store.positions, {})
store.save({'600000.SH': position}, []) store.sync_positions([PositionItem(stock_code='600001.SH')])
self.assertEqual(store.positions['600000.SH'], position)
store.save({}, [])
store.save({'600001.SH': {'code': '600001.SH', 'status': READY}}, [])
self.assertGreater(store.positions['600001.SH']['id'], first_id) self.assertGreater(store.positions['600001.SH']['id'], first_id)
def test_base_split_fills_and_snapshot_do_not_double_count(self): def test_base_split_fills_and_snapshot_do_not_double_count(self):
@@ -207,12 +203,12 @@ class OrderBookTests(unittest.TestCase):
state = TState(self.path) state = TState(self.path)
first = self.deal('base', 'd1', 100, 10, '20260901') first = self.deal('base', 'd1', 100, 10, '20260901')
other = self.deal('base', 'd2', 100, 10) other = self.deal('base', 'd2', 100, 10)
other.local_order_id = 'trend-base-order' other.remark = 'trend-base-order'
state.reconcile([], [first, other]) state.reconcile([], [first, other])
first.insert_date = '2026-09-01' first.trade_date = '2026-09-01'
state.reconcile([], [first]) state.reconcile([], [first])
self.assertEqual(len(state.deals), 1) self.assertEqual(len(state.deals), 1)
self.assertEqual(state.deals[0]['insert_date'], '2026-09-01') self.assertEqual(state.deals[0]['trade_date'], '2026-09-01')
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -0,0 +1,88 @@
import importlib
import io
import logging
import unittest
from concurrent.futures import Future
from contextlib import ExitStack, redirect_stdout
from types import SimpleNamespace
from unittest.mock import Mock, patch
from libs import collector
from sdk import Assets, PositionItem
from strategy.trend import boot
class TrendCollectorTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
with patch('logging.FileHandler', return_value=logging.NullHandler()):
cls.app = importlib.import_module('main')
def setUp(self):
old_snapshot = boot._collector_snapshot
self.addCleanup(setattr, boot, '_collector_snapshot', old_snapshot)
boot._collector_snapshot = None
def test_submission_reads_latest_cache_and_skips_empty(self):
with patch.object(collector, 'collector_push') as push:
collector.submit_trend_data()
push.assert_not_called()
boot._cache_portfolio('account', Assets(available=100), [])
assets = Assets(available=200)
positions = [PositionItem(stock_code='600000.SH', volume=100)]
boot._cache_portfolio('account', assets, positions)
collector.submit_trend_data()
push.assert_called_once_with('account', assets, positions)
uploaded = push.call_args.args
uploaded[1].available = 0
uploaded[2].clear()
self.assertEqual(boot.get_collector_snapshot()[1].available, 200)
self.assertEqual(len(boot.get_collector_snapshot()[2]), 1)
def test_run_once_caches_portfolio_without_submitting_data(self):
completed = Future()
completed.set_result(None)
run = SimpleNamespace(
client=Mock(), orders=Mock(), executor=Mock(),
account_cfg=SimpleNamespace(account_id='account', min_cash_ratio=0.1),
)
assets = Assets(available=100, total=1000)
run.client.portfolio.return_value = SimpleNamespace(assets=assets, positions={}, orders=[])
run.client.full_tick.return_value = {}
run.executor.submit.return_value = completed
with patch.object(boot, 'trading_time', return_value=True), \
patch.object(boot, 'market_allow_open', return_value=True), \
patch.object(collector, 'collector_push') as push, redirect_stdout(io.StringIO()):
boot.RunOnce(run, [])
self.assertEqual(boot.get_collector_snapshot(), ('account', assets, []))
push.assert_not_called()
run.executor.submit.assert_called_once_with(boot.manage_positions, run, {}, [], True, 100)
def test_main_registers_five_minute_collector_job(self):
for strategy in ('trend', 'zt'):
with self.subTest(strategy=strategy), ExitStack() as stack:
scheduler = Mock(running=True)
stack.enter_context(patch.object(self.app, 'BackgroundScheduler', return_value=scheduler))
stack.enter_context(patch.object(self.app, 'require_windows', return_value=True))
stack.enter_context(patch.object(self.app, 'check_single_instance', return_value=True))
stack.enter_context(patch.object(self.app, 'wait_for_qmt_api'))
stack.enter_context(patch.object(self.app.config, 'load'))
stack.enter_context(patch.object(self.app.config, 'global_config', SimpleNamespace(api_host='unused')))
stack.enter_context(patch.object(self.app.config, 'account_config', SimpleNamespace(strategy=strategy)))
stack.enter_context(patch.dict(self.app.STRATEGIES, {
strategy: SimpleNamespace(start_strategy=Mock()),
}))
self.assertEqual(self.app.main(), 0)
jobs = [call for call in scheduler.add_job.call_args_list
if call.kwargs.get('id') == 'trend_collector']
self.assertEqual(len(jobs), 1)
if jobs:
self.assertIs(jobs[0].args[0], collector.submit_trend_data)
self.assertEqual(jobs[0].kwargs['trigger'], 'interval')
self.assertEqual(jobs[0].kwargs['minutes'], 5)
scheduler.start.assert_called_once()
scheduler.shutdown.assert_called_once_with(wait=True)
if __name__ == '__main__':
unittest.main()