feat libs,sdk,trend
This commit is contained in:
@@ -174,7 +174,7 @@ class PortfolioHandler(BaseHandler):
|
||||
result = {
|
||||
"assets": format_assets(account_data),
|
||||
"positions": format_holding(positions),
|
||||
"orders": [fixed_fields(order) for order in orders],
|
||||
"orders": format_orders(orders),
|
||||
}
|
||||
self.write_json(result)
|
||||
|
||||
@@ -183,8 +183,7 @@ class PortfolioHandler(BaseHandler):
|
||||
class HoldingHandler(BaseHandler):
|
||||
def get(self):
|
||||
positions = safe_call(get_trade_detail_data, self.acc(), 'stock', 'position') or []
|
||||
holding = format_holding(positions)
|
||||
self.write_json({"data": holding})
|
||||
self.write_json(format_holding(positions))
|
||||
|
||||
class OrgHandler(BaseHandler):
|
||||
def get(self, handler_type):
|
||||
@@ -207,22 +206,18 @@ class OrgHandler(BaseHandler):
|
||||
# get_trade_detail_data('account') - Query account assets
|
||||
class AssetsHandler(BaseHandler):
|
||||
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))
|
||||
|
||||
class OrderHandler(BaseHandler):
|
||||
def get(self):
|
||||
ret = safe_call(get_trade_detail_data, self.acc(), 'stock', 'order')
|
||||
if ret is None:
|
||||
ret = []
|
||||
result = [fixed_fields(obj) for obj in ret]
|
||||
self.write_json(result)
|
||||
ret = safe_call(get_trade_detail_data, self.acc(), 'stock', 'order') or []
|
||||
self.write_json(format_orders(ret))
|
||||
|
||||
class DealHandler(BaseHandler):
|
||||
def get(self):
|
||||
deals = safe_call(get_trade_detail_data, self.acc(), 'stock', 'deal') or []
|
||||
rets = [fixed_fields(deal) for deal in deals]
|
||||
self.write_json({"deals": rets})
|
||||
self.write_json(format_deals(deals))
|
||||
|
||||
# ContextInfo.get_full_tick() - Get full tick data
|
||||
class FullTickHandler(BaseHandler):
|
||||
@@ -330,56 +325,70 @@ def format_holding(positions):
|
||||
for position in positions:
|
||||
stock = position.m_strInstrumentID + '.' + position.m_strExchangeID
|
||||
holding[stock] = {
|
||||
'StockCode': stock,
|
||||
'TradeID':position.m_strTradeID,
|
||||
'StockName': position.m_strInstrumentName,
|
||||
'Direction': position.m_nDirection,
|
||||
'Volume': position.m_nVolume,
|
||||
'OpenPrice': position.m_dOpenPrice,
|
||||
'OpenCost':position.m_dOpenCost,
|
||||
'FloatProfit': position.m_dFloatProfit,
|
||||
'MarketValue': position.m_dMarketValue,
|
||||
'StockHolder': position.m_strStockHolder,
|
||||
'FrozenVolume': position.m_nFrozenVolume,
|
||||
'CanUseVolume': position.m_nCanUseVolume,
|
||||
'OnRoadVolume': position.m_nOnRoadVolume,
|
||||
'YesterdayVolume': position.m_nYesterdayVolume,
|
||||
'LastPrice': position.m_dLastPrice,
|
||||
'ProfitRate': position.m_dProfitRate,
|
||||
'FutureTradeType': position.m_eFutureTradeType,
|
||||
'ExpireDate': position.m_strExpireDate
|
||||
'stock_code': stock,
|
||||
'stock_name': position.m_strInstrumentName,
|
||||
'direction': position.m_nDirection,
|
||||
'volume': position.m_nVolume,
|
||||
'open_price': position.m_dOpenPrice,
|
||||
'open_cost':position.m_dOpenCost,
|
||||
'float_profit': position.m_dFloatProfit,
|
||||
'market_value': position.m_dMarketValue,
|
||||
'stock_holder': position.m_strStockHolder,
|
||||
'frozen_volume': position.m_nFrozenVolume,
|
||||
'can_use_volume': position.m_nCanUseVolume,
|
||||
'on_road_volume': position.m_nOnRoadVolume,
|
||||
'yesterday_volume': position.m_nYesterdayVolume,
|
||||
'last_price': position.m_dLastPrice,
|
||||
'profit_rate': position.m_dProfitRate,
|
||||
'future_trade_type': position.m_eFutureTradeType,
|
||||
'expire_date': position.m_strExpireDate
|
||||
}
|
||||
return holding
|
||||
|
||||
TRADE_DETAIL_FIELDS = (
|
||||
'm_strOrderSysID', 'm_strInstrumentID', 'm_strExchangeID',
|
||||
'm_strInstrumentName', 'm_nOffsetFlag', 'm_nOrderStatus',
|
||||
'm_nVolumeTotal', 'm_nVolumeTraded', 'm_nOrderTime',
|
||||
'm_strInsertDate', 'm_strInsertTime', 'm_strRemark',
|
||||
'm_dPrice', 'm_dTradePrice', 'm_dTradeAmount',
|
||||
)
|
||||
MISSING = object()
|
||||
|
||||
|
||||
def fixed_fields(obj, fields=TRADE_DETAIL_FIELDS):
|
||||
result = {}
|
||||
for field in fields:
|
||||
try:
|
||||
value = getattr(obj, field, MISSING)
|
||||
except TypeError:
|
||||
continue
|
||||
if value is MISSING:
|
||||
continue
|
||||
if not callable(value):
|
||||
result[field] = str(value)
|
||||
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)
|
||||
}
|
||||
def format_orders(orders):
|
||||
"""显式转换委托/成交字段,保留原始数值类型。"""
|
||||
result = []
|
||||
for order in orders:
|
||||
result.append({
|
||||
'stock_code': order.m_strInstrumentID + '.' + order.m_strExchangeID,
|
||||
'order_sys_id': order.m_strOrderSysID,
|
||||
'ref': order.m_nRef,
|
||||
'order_ref': order.m_strOrderRef,
|
||||
'direction': order.m_nDirection,
|
||||
'offset_flag': order.m_nOffsetFlag,
|
||||
'limit_price': order.m_dLimitPrice,
|
||||
'volume_total_original': order.m_nVolumeTotalOriginal,
|
||||
'volume_traded': order.m_nVolumeTraded,
|
||||
'volume_total': order.m_nVolumeTotal,
|
||||
'traded_price': order.m_dTradedPrice,
|
||||
'trade_amount': order.m_dTradeAmount,
|
||||
'insert_date': order.m_strInsertDate,
|
||||
'insert_time': order.m_strInsertTime,
|
||||
'remark': order.m_strRemark,
|
||||
'order_status': order.m_nOrderStatus,
|
||||
})
|
||||
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 =============
|
||||
def make_app():
|
||||
|
||||
45
docs/zt.md
45
docs/zt.md
@@ -19,39 +19,22 @@ zt_max_price: 200
|
||||
## SQLite 状态存储
|
||||
|
||||
`libs/orderbook.py` 使用标准库 `sqlite3`,数据库路径为
|
||||
`{qmt_data_dir}/zt_{account_id}_state.db`,每个账户/策略独立存储,由一个策略实例串行更新。
|
||||
启动时仅创建当前表结构和索引,不执行数据迁移或旧 JSON 导入。
|
||||
`{qmt_data_dir}/zt_{account_id}_state.db`,每个账户/策略由单个实例串行更新。
|
||||
启动时仅创建当前表结构和索引,不执行迁移或旧 JSON 导入。
|
||||
|
||||
`OrderBook` 初始化后调用 `load()`,填充 `positions`(按代码)、`deals`(按系统订单号)
|
||||
和 `deals_sys_ids`(系统订单号集合)。`sync_deals(list[DealItem])` 根据该集合过滤已保存及
|
||||
同批重复成交,再以单个事务批量插入,提交成功后刷新缓存;不修改持仓。
|
||||
`sys_order_id` 对应 API 的 `m_strOrderSysID`,`local_order_id` 从 `m_strRemark` 的首段提取。
|
||||
两表使用 SDK 同名字段;另有自增主键 `id`,成交表增加从 `remark` 提取的 `order_local_id`:
|
||||
|
||||
| 表 | 字段与用途 | 索引 |
|
||||
| 表 | 数据模型 | 索引 |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
| `deals` | `id`、系统/本地订单号、证券信息、方向、API 状态、剩余/成交数量、委托日期时间、备注、委托价格及成交均价/金额,字段映射见下表 | `id` 主键;`sys_order_id` 唯一;`local_order_id`;`(code, insert_date)`;`(insert_date, insert_time)` |
|
||||
| `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` | `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 字段 |
|
||||
| --- | --- |
|
||||
| `m_strOrderSysID` | `sys_order_id` |
|
||||
| `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 买回不计为补仓。
|
||||
ZT 使用 `volume/open_price` 保存底仓数量与成本,做 T 轮次从成交历史恢复,
|
||||
不再使用持仓表的旧状态、底仓订单或补仓字段。买卖方向由 `offset_flag` 计算,
|
||||
本地订单号从 `remark` 提取。持仓与新增成交在同一事务提交,失败时回滚并恢复内存。
|
||||
|
||||
Binary file not shown.
BIN
py-client/libs/__pycache__/order.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/order.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/orderbook.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/orderbook.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/overview.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/overview.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/runtime.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/runtime.cpython-311.pyc
Normal file
Binary file not shown.
BIN
py-client/libs/__pycache__/watch.cpython-311.pyc
Normal file
BIN
py-client/libs/__pycache__/watch.cpython-311.pyc
Normal file
Binary file not shown.
@@ -9,6 +9,15 @@ import httpx
|
||||
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:
|
||||
"""Convert the QMT model values into values accepted by a JSON encoder."""
|
||||
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:
|
||||
"""Best-effort collector upload; never propagate errors to the caller."""
|
||||
"""[暂停] 数据收集提交,太耗时,超过200毫秒."""
|
||||
try:
|
||||
payload = _json_value(
|
||||
{
|
||||
|
||||
@@ -70,29 +70,29 @@ class OrderBook:
|
||||
|
||||
for item in orders:
|
||||
# 不处理状态不对的
|
||||
if item.status not in TRACKED_STATUSES:
|
||||
if str(item.order_status) not in TRACKED_STATUSES:
|
||||
continue
|
||||
if item.status in BUSY_STATUSES:
|
||||
busy_keys.add(self._busy_key(item.side, item.code))
|
||||
if str(item.order_status) in BUSY_STATUSES:
|
||||
busy_keys.add(self._busy_key(item.side, item.stock_code))
|
||||
# 清理过期的
|
||||
if (
|
||||
item.created_at is not None
|
||||
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
|
||||
):
|
||||
try:
|
||||
client.cancel_by_id(item.id)
|
||||
client.cancel_by_id(item.order_sys_id)
|
||||
canceled += 1
|
||||
logging.info(
|
||||
"[Order] 超时撤单,代码=%s,方向=%s,柜台订单=%s",
|
||||
item.code,
|
||||
item.stock_code,
|
||||
item.side,
|
||||
item.id,
|
||||
item.order_sys_id,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"[Order] 撤单失败,保留在途状态,订单=%s", item.id
|
||||
"[Order] 撤单失败,保留在途状态,订单=%s", item.order_sys_id
|
||||
)
|
||||
|
||||
# 缓存本次有效订单
|
||||
|
||||
@@ -1,62 +1,79 @@
|
||||
"""SQLite 状态簿:每个账户/策略使用独立数据库,单个策略串行读写。"""
|
||||
"""SQLite positions and deals, aligned with SDK models; one writer per database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict
|
||||
from dataclasses import asdict, fields
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sdk import DealItem
|
||||
from sdk import DealItem, PositionItem
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL,
|
||||
base_order_id TEXT NOT NULL DEFAULT '',
|
||||
base_qty INTEGER NOT NULL DEFAULT 0 CHECK (base_qty >= 0),
|
||||
base_cost REAL NOT NULL DEFAULT 0.0 CHECK (base_cost >= 0),
|
||||
added_order_id TEXT NOT NULL DEFAULT '',
|
||||
added_num INTEGER NOT NULL DEFAULT 0 CHECK (added_num >= 0),
|
||||
added_qty INTEGER NOT NULL DEFAULT 0 CHECK (added_qty >= 0),
|
||||
added_cost REAL NOT NULL DEFAULT 0.0 CHECK (added_cost >= 0),
|
||||
status TEXT NOT NULL
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL DEFAULT '',
|
||||
direction INTEGER,
|
||||
volume INTEGER NOT NULL DEFAULT 0 CHECK (volume >= 0),
|
||||
open_price REAL NOT NULL DEFAULT 0,
|
||||
open_cost REAL NOT NULL DEFAULT 0,
|
||||
float_profit REAL NOT NULL DEFAULT 0,
|
||||
market_value REAL NOT NULL DEFAULT 0,
|
||||
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 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 UNIQUE INDEX IF NOT EXISTS idx_positions_stock_code ON positions (stock_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deals (
|
||||
id INTEGER PRIMARY KEY,
|
||||
sys_order_id TEXT NOT NULL UNIQUE CHECK (sys_order_id <> ''),
|
||||
local_order_id TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
instrument_id TEXT NOT NULL,
|
||||
exchange_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
offset_flag TEXT NOT NULL,
|
||||
side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
|
||||
status TEXT NOT NULL,
|
||||
remaining_volume INTEGER NOT NULL CHECK (remaining_volume >= 0),
|
||||
traded_volume INTEGER NOT NULL CHECK (traded_volume > 0),
|
||||
order_time INTEGER NOT NULL,
|
||||
insert_date TEXT NOT NULL,
|
||||
insert_time TEXT NOT NULL,
|
||||
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)
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stock_code TEXT NOT NULL,
|
||||
order_sys_id TEXT NOT NULL CHECK (order_sys_id <> ''),
|
||||
order_local_id TEXT NOT NULL CHECK (order_local_id <> ''),
|
||||
ref INTEGER NOT NULL DEFAULT 0,
|
||||
order_ref TEXT NOT NULL DEFAULT '',
|
||||
direction INTEGER NOT NULL DEFAULT 0,
|
||||
offset_flag INTEGER NOT NULL CHECK (offset_flag IN (23, 24, 48, 49)),
|
||||
price REAL NOT NULL CHECK (price >= 0),
|
||||
volume INTEGER NOT NULL CHECK (volume > 0),
|
||||
trade_amount REAL NOT NULL CHECK (trade_amount > 0),
|
||||
trade_date TEXT NOT NULL,
|
||||
trade_time TEXT NOT NULL,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
close_profit REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_local_order_id ON deals (local_order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_code_date ON deals (code, insert_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (insert_date, insert_time);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_deals_order_sys_id ON deals (order_sys_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_order_ref ON deals (order_local_id);
|
||||
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:
|
||||
"""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:
|
||||
self.path = Path(path)
|
||||
@@ -76,8 +93,8 @@ class OrderBook:
|
||||
def load(self) -> None:
|
||||
with closing(self._connect()) as db, db:
|
||||
db.execute('BEGIN')
|
||||
positions = {row['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')}
|
||||
positions = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM positions')}
|
||||
deals = {row['order_sys_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')}
|
||||
self.positions = positions
|
||||
self.deals = deals
|
||||
self.deals_sys_ids = set(deals)
|
||||
@@ -85,86 +102,58 @@ class OrderBook:
|
||||
|
||||
@staticmethod
|
||||
def _insert_deals(db: sqlite3.Connection, deals: list[dict]) -> None:
|
||||
db.executemany(
|
||||
"""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,
|
||||
)
|
||||
db.executemany(DEAL_INSERT, deals)
|
||||
|
||||
@staticmethod
|
||||
def deal_record(deal: DealItem) -> dict:
|
||||
"""API 字段转入库记录;委托价格不用于推算成交金额。"""
|
||||
if not deal.sys_order_id or deal.traded_volume <= 0:
|
||||
raise ValueError('System order ID and positive traded volume are required')
|
||||
if not deal.order_sys_id or deal.volume <= 0:
|
||||
raise ValueError('System order ID and positive volume are required')
|
||||
if not deal.local_order_id:
|
||||
raise ValueError('Local order ID is required')
|
||||
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()):
|
||||
raise ValueError('Prices and amounts must be finite')
|
||||
amount = deal.trade_amount if deal.trade_amount > 0 else deal.trade_price * deal.traded_volume
|
||||
raise ValueError('Numeric values must be finite')
|
||||
amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
|
||||
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
|
||||
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():
|
||||
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
|
||||
row['insert_date'] = date
|
||||
row['trade_date'] = date
|
||||
return row
|
||||
|
||||
def sync_deals(self, deals: list[DealItem]) -> None:
|
||||
"""按系统订单号去重,批量写入已成交数据;失败时不更新缓存。"""
|
||||
new_deals = []
|
||||
seen = self.deals_sys_ids.copy()
|
||||
for deal in deals:
|
||||
if deal.sys_order_id in seen:
|
||||
if deal.order_sys_id in seen:
|
||||
continue
|
||||
new_deals.append(self.deal_record(deal))
|
||||
seen.add(deal.sys_order_id)
|
||||
seen.add(deal.order_sys_id)
|
||||
if not new_deals:
|
||||
return
|
||||
with closing(self._connect()) as db, db:
|
||||
self._insert_deals(db, new_deals)
|
||||
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:
|
||||
if len(deals) < self._deal_count:
|
||||
raise ValueError('Execution history is append-only')
|
||||
new_deals = deals[self._deal_count:]
|
||||
positions = [
|
||||
{
|
||||
'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]:
|
||||
positions = [{**asdict(PositionItem()), **item} for item in items.values()]
|
||||
for row in [*positions, *new_deals]:
|
||||
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:
|
||||
# 更新已有证券时保留其自增 ID;仅删除快照中已移除的证券。
|
||||
for row in db.execute('SELECT code FROM positions').fetchall():
|
||||
if row['code'] not in items:
|
||||
db.execute('DELETE FROM positions WHERE code = ?', (row['code'],))
|
||||
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,
|
||||
)
|
||||
for row in db.execute('SELECT stock_code FROM positions').fetchall():
|
||||
if row['stock_code'] not in items:
|
||||
db.execute('DELETE FROM positions WHERE stock_code = ?', (row['stock_code'],))
|
||||
db.executemany(POSITION_UPSERT, positions)
|
||||
self._insert_deals(db, new_deals)
|
||||
self.load()
|
||||
|
||||
@@ -26,8 +26,7 @@ def Overview(assets, positions, account_cfg=None) -> None:
|
||||
if position.volume <= 0:
|
||||
continue
|
||||
log.info(
|
||||
"[启动] %s %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%",
|
||||
position.trade_id,
|
||||
"[启动] %s %s,持仓=%d,可用=%d,成本=%.2f(%.2f),现价=%.2f,盈亏=%.2f%%",
|
||||
position.stock_code,
|
||||
position.stock_name,
|
||||
position.volume,
|
||||
|
||||
@@ -34,6 +34,7 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
from sdk import APIError, Client
|
||||
from libs.market import refresh_market
|
||||
from libs.collector import submit_trend_data
|
||||
from strategy.trend.boot import StartTrend
|
||||
from strategy.zt.boot import StartZT
|
||||
from strategy.ipo import AutoBuyIpo
|
||||
@@ -109,6 +110,7 @@ def wait_for_any_key() -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
scheduler = None
|
||||
try:
|
||||
if not require_windows():
|
||||
logging.error("本程序仅支持 Windows 环境运行")
|
||||
@@ -143,18 +145,29 @@ def main() -> int:
|
||||
replace_existing=True,
|
||||
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()
|
||||
logging.info("IPO 自动打新定时任务已启动:每日 10:00、14:00")
|
||||
logging.info("大盘信号后台刷新已启动:每分钟一次")
|
||||
|
||||
STRATEGIES[config.account_config.strategy].start_strategy()
|
||||
logging.info("%s 策略启动成功",config.account_config.strateg)
|
||||
logging.info("%s 策略已结束", config.account_config.strategy)
|
||||
return 0
|
||||
except (OSError, yaml.YAMLError, ValueError, RuntimeError, KeyError) as e:
|
||||
print(f"启动失败: {e}", file=sys.stderr, flush=True)
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
wait_for_any_key()
|
||||
return 1
|
||||
finally:
|
||||
if scheduler is not None and scheduler.running:
|
||||
scheduler.shutdown(wait=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,118 +13,75 @@ def _number(value: Any, kind: type = float) -> Any:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderItem:
|
||||
"""由 QMT 委托明细解析得到的标准订单记录。"""
|
||||
"""Fields match format_orders() exactly."""
|
||||
|
||||
id: str
|
||||
code: str
|
||||
side: str
|
||||
remark: str
|
||||
status: str
|
||||
created_at: datetime | None
|
||||
volume: int
|
||||
local_order_id: str = ""
|
||||
traded_volume: int = 0
|
||||
remaining_volume: int = 0
|
||||
exchange_id: str = ""
|
||||
name: str = ""
|
||||
price: float = 0.0
|
||||
trade_price: float = 0.0
|
||||
stock_code: str = ""
|
||||
order_sys_id: str = ""
|
||||
ref: int = 0
|
||||
order_ref: str = ""
|
||||
direction: int = 0
|
||||
offset_flag: int = 0
|
||||
limit_price: float = 0.0
|
||||
volume_total_original: int = 0
|
||||
volume_traded: int = 0
|
||||
volume_total: int = 0
|
||||
traded_price: float = 0.0
|
||||
trade_amount: float = 0.0
|
||||
insert_date: str = ""
|
||||
insert_time: str = ""
|
||||
remark: str = ""
|
||||
order_status: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_trade_detail(cls, data: dict[str, Any]) -> "OrderItem":
|
||||
"""从 TradeDetailData 的 QMT 原始字段创建订单。"""
|
||||
instrument_id = str(data.get("m_strInstrumentID") or "")
|
||||
exchange_id = str(data.get("m_strExchangeID") or "")
|
||||
code = (
|
||||
f"{instrument_id}.{exchange_id}"
|
||||
if instrument_id and exchange_id
|
||||
else instrument_id
|
||||
)
|
||||
remaining_volume = _number(data.get("m_nVolumeTotal"), int)
|
||||
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")),
|
||||
)
|
||||
@property
|
||||
def side(self) -> str:
|
||||
return _side(self.offset_flag)
|
||||
|
||||
@property
|
||||
def local_order_id(self) -> str:
|
||||
return self.remark.split("|", 1)[0]
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime | None:
|
||||
return _parse_datetime(self.insert_date, self.insert_time)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DealItem:
|
||||
"""Execution data from the API's fixed order-detail fields."""
|
||||
"""Fields match format_deals() exactly."""
|
||||
|
||||
sys_order_id: str = ""
|
||||
local_order_id: str = ""
|
||||
code: str = ""
|
||||
instrument_id: str = ""
|
||||
exchange_id: str = ""
|
||||
name: str = ""
|
||||
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 = ""
|
||||
stock_code: str = ""
|
||||
order_sys_id: str = ""
|
||||
ref: int = 0
|
||||
order_ref: str = ""
|
||||
direction: int = 0
|
||||
offset_flag: int = 0
|
||||
price: float = 0.0
|
||||
trade_price: float = 0.0
|
||||
volume: int = 0
|
||||
trade_amount: float = 0.0
|
||||
trade_date: str = ""
|
||||
trade_time: str = ""
|
||||
remark: str = ""
|
||||
close_profit: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_trade_detail(cls, data: dict[str, Any]) -> "DealItem":
|
||||
instrument_id = str(data.get("m_strInstrumentID") or "")
|
||||
exchange_id = str(data.get("m_strExchangeID") or "")
|
||||
remark = str(data.get("m_strRemark") or "")
|
||||
offset_flag = str(data.get("m_nOffsetFlag") or "")
|
||||
return cls(
|
||||
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")),
|
||||
)
|
||||
@property
|
||||
def side(self) -> str:
|
||||
return _side(self.offset_flag)
|
||||
|
||||
@property
|
||||
def local_order_id(self) -> str:
|
||||
return self.remark.split("|", 1)[0]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PositionItem:
|
||||
"""Fields match format_holding() exactly."""
|
||||
|
||||
stock_code: str = ""
|
||||
stock_name: str = ""
|
||||
trade_id: str = ""
|
||||
direction: Any = None
|
||||
volume: int = 0
|
||||
open_cost: float = 0.0
|
||||
open_price: float = 0.0
|
||||
open_cost: float = 0.0
|
||||
float_profit: float = 0.0
|
||||
market_value: float = 0.0
|
||||
stock_holder: str = ""
|
||||
@@ -137,67 +94,14 @@ class PositionItem:
|
||||
future_trade_type: Any = None
|
||||
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)
|
||||
class Assets:
|
||||
"""Fields match format_assets() exactly."""
|
||||
|
||||
total: 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)
|
||||
class Portfolio:
|
||||
@@ -206,17 +110,14 @@ class Portfolio:
|
||||
orders: list[OrderItem]
|
||||
|
||||
|
||||
def _trade_datetime(data: dict[str, Any]) -> datetime | None:
|
||||
return _parse_datetime(
|
||||
str(data.get("m_strInsertDate") or ""),
|
||||
str(data.get("m_strInsertTime") or ""),
|
||||
)
|
||||
def _side(offset_flag: int) -> str:
|
||||
return {"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(str(offset_flag), "")
|
||||
|
||||
|
||||
def _parse_datetime(date: str, clock: str) -> datetime | None:
|
||||
clock = clock.replace(":", "").zfill(6)
|
||||
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:
|
||||
return None
|
||||
|
||||
|
||||
@@ -17,33 +17,33 @@ class PortfolioMixin:
|
||||
def portfolio(self) -> Portfolio:
|
||||
data = self._get_json("/api/portfolio") or {}
|
||||
positions = {
|
||||
code: PositionItem.from_dict(value, code)
|
||||
code: PositionItem(**value)
|
||||
for code, value in data.get("positions", {}).items()
|
||||
}
|
||||
return Portfolio(
|
||||
assets=Assets.from_dict(data.get("assets", {})),
|
||||
assets=Assets(**data.get("assets", {})),
|
||||
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]]:
|
||||
data = self._get_json("/api/portfolio/positions") or {}
|
||||
positions = [
|
||||
PositionItem.from_dict(value, code)
|
||||
for code, value in data.get("data", {}).items()
|
||||
PositionItem(**value)
|
||||
for value in data.values()
|
||||
]
|
||||
return [item.stock_code for item in positions], positions
|
||||
return list(data), positions
|
||||
|
||||
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]:
|
||||
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]:
|
||||
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:
|
||||
datatype = str(datatype).strip().lower()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,15 +8,16 @@ from __future__ import annotations
|
||||
import time
|
||||
import logging as log
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
import config
|
||||
from libs.calc import trading_time
|
||||
from libs.market import market_allow_open
|
||||
from libs.overview import Overview
|
||||
from libs.signal import init_signals, SignalItem
|
||||
from libs.collector import collector_push
|
||||
from sdk import Client
|
||||
from sdk import Assets, Client, PositionItem
|
||||
from libs.grid_take_profit import GridTrailingTracker
|
||||
from libs.order import OrderBook
|
||||
from libs.watch import DipWatch
|
||||
@@ -24,6 +25,23 @@ from libs.runtime import Runtime
|
||||
from .open import open_signal
|
||||
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:
|
||||
"""初始化趋势策略,并以 30 秒间隔持续执行。"""
|
||||
@@ -37,6 +55,7 @@ def StartTrend() -> None:
|
||||
portfolio = client.portfolio()
|
||||
assets = portfolio.assets
|
||||
positions = list(portfolio.positions.values())
|
||||
_cache_portfolio(config.account_config.account_id, assets, positions)
|
||||
order_book = OrderBook("trend")
|
||||
order_book.refresh(client, portfolio.orders)
|
||||
|
||||
@@ -51,7 +70,7 @@ def StartTrend() -> None:
|
||||
len(signals),
|
||||
len(positions),
|
||||
)
|
||||
executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="trend")
|
||||
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="trend")
|
||||
run = Runtime(
|
||||
client=client,
|
||||
global_cfg=config.global_config,
|
||||
@@ -116,22 +135,13 @@ def RunOnce(run: Runtime, signals: list[SignalItem]) -> None:
|
||||
assets = portfolio.assets
|
||||
position_codes = list(portfolio.positions)
|
||||
positions = list(portfolio.positions.values())
|
||||
_cache_portfolio(run.account_cfg.account_id, assets, positions)
|
||||
run.orders.refresh(run.client, portfolio.orders)
|
||||
except Exception:
|
||||
log.exception("[Portfolio] 刷新账户快照失败")
|
||||
return
|
||||
|
||||
futures: list[tuple[str, Future]] = [
|
||||
(
|
||||
"数据提交",
|
||||
run.executor.submit(
|
||||
collector_push,
|
||||
run.account_cfg.account_id,
|
||||
assets,
|
||||
positions,
|
||||
),
|
||||
)
|
||||
]
|
||||
futures: list[tuple[str, Future]] = []
|
||||
|
||||
# 2. 验证可用资金;低于资金安全线时禁止开新仓。
|
||||
allow_open_by_cash = (
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -60,9 +60,9 @@ class TState:
|
||||
@classmethod
|
||||
def _apply_t_deal(cls, item: TStateItem, deal: dict) -> None:
|
||||
"""实时入账与重启恢复共用同一套做 T 轮次计算。"""
|
||||
cls._reset(item, deal['insert_date'])
|
||||
qty, amount = deal['traded_volume'], deal['trade_amount']
|
||||
if deal['side'] == 'SELL':
|
||||
cls._reset(item, deal['trade_date'])
|
||||
qty, amount = deal['volume'], deal['trade_amount']
|
||||
if str(deal['offset_flag']) in ('24', '49'):
|
||||
total = item.sell_qty + qty
|
||||
item.sell_price = (item.sell_qty * item.sell_price + amount) / total
|
||||
item.sell_qty = total
|
||||
@@ -72,32 +72,32 @@ class TState:
|
||||
item.buy_cost = (item.buy_qty * item.buy_cost + amount) / total
|
||||
item.buy_qty = total
|
||||
item.phase = DONE if total >= item.sell_qty else SOLD
|
||||
item.trade_date = deal['insert_date']
|
||||
item.trade_date = deal['trade_date']
|
||||
|
||||
def reconcile(
|
||||
self, positions: list[PositionItem], deals: list[DealItem]
|
||||
) -> None:
|
||||
"""Deduplicate each fill; partial fills do not wait for order completion."""
|
||||
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 = []
|
||||
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
|
||||
try:
|
||||
row = self._store.deal_record(deal)
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append(row)
|
||||
seen.add(deal.sys_order_id)
|
||||
rows.sort(key=lambda r: (r['insert_date'], r['insert_time']))
|
||||
seen.add(deal.order_sys_id)
|
||||
rows.sort(key=lambda r: (r['trade_date'], r['trade_time']))
|
||||
modified = False
|
||||
try:
|
||||
# Snapshot includes these fills: subtract their net quantity before replay.
|
||||
net = {}
|
||||
for row in rows:
|
||||
net[row['code']] = net.get(row['code'], 0) + (
|
||||
row['traded_volume'] if row['side'] == 'BUY' else -row['traded_volume']
|
||||
net[row['stock_code']] = net.get(row['stock_code'], 0) + (
|
||||
row['volume'] if str(row['offset_flag']) in ('23', '48') else -row['volume']
|
||||
)
|
||||
for position in positions:
|
||||
code = position.stock_code
|
||||
@@ -115,14 +115,14 @@ class TState:
|
||||
self.items[code] = TStateItem(code, -delta)
|
||||
|
||||
for row in rows:
|
||||
item = self.items.setdefault(row['code'], TStateItem(row['code']))
|
||||
self._reset(item, row['insert_date'])
|
||||
qty, amount = row['traded_volume'], row['trade_amount']
|
||||
if row['local_order_id'].startswith('zt-base-'):
|
||||
item = self.items.setdefault(row['stock_code'], TStateItem(row['stock_code']))
|
||||
self._reset(item, row['trade_date'])
|
||||
qty, amount = row['volume'], row['trade_amount']
|
||||
if row['order_local_id'].startswith('zt-base-'):
|
||||
total = item.base_qty + qty
|
||||
item.base_cost = (item.base_qty * item.base_cost + amount) / total
|
||||
item.base_qty = total
|
||||
item.base_order_id = row['local_order_id']
|
||||
item.base_order_id = row['order_local_id']
|
||||
else:
|
||||
self._apply_t_deal(item, row)
|
||||
self.deals.append(row)
|
||||
@@ -141,15 +141,10 @@ class TState:
|
||||
self._store.save(
|
||||
{
|
||||
code: {
|
||||
'code': item.code,
|
||||
'base_order_id': item.base_order_id,
|
||||
'base_qty': item.base_qty,
|
||||
'base_cost': 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,
|
||||
'stock_code': item.code,
|
||||
'volume': item.base_qty,
|
||||
'open_price': item.base_cost,
|
||||
'open_cost': item.base_qty * item.base_cost,
|
||||
}
|
||||
for code, item in self.items.items()
|
||||
},
|
||||
@@ -163,19 +158,21 @@ class TState:
|
||||
self._store.load()
|
||||
self.items = {}
|
||||
for code, position in self._store.positions.items():
|
||||
position = dict(position)
|
||||
position['phase'] = position.pop('status')
|
||||
self.items[code] = TStateItem(**position)
|
||||
self.items[code] = TStateItem(code, position['volume'], position['open_price'], id=position['id'])
|
||||
self.deals = [
|
||||
{key: value for key, value in deal.items() if key != 'id'}
|
||||
for deal in self._store.deals.values()
|
||||
]
|
||||
# 轮次明细不占用持仓表字段,从已保存的逐笔成交重建。
|
||||
for deal in self.deals:
|
||||
if not deal['local_order_id'].startswith('zt-base-') and deal['code'] in self.items:
|
||||
self._apply_t_deal(self.items[deal['code']], deal)
|
||||
for code, item in self.items.items():
|
||||
if self._store.positions[code]['status'] == READY:
|
||||
item.phase, item.trade_date = READY, ''
|
||||
item.sell_qty = item.buy_qty = 0
|
||||
item.sell_price = item.buy_cost = 0.0
|
||||
item = self.items.get(deal['stock_code'])
|
||||
if item is None:
|
||||
continue
|
||||
local_order_id = deal['order_local_id']
|
||||
if local_order_id.startswith('zt-base-'):
|
||||
item.base_order_id = local_order_id
|
||||
else:
|
||||
self._apply_t_deal(item, deal)
|
||||
today = datetime.now().date().isoformat()
|
||||
for item in self.items.values():
|
||||
self._reset(item, today)
|
||||
|
||||
@@ -1,75 +1,89 @@
|
||||
import sqlite3
|
||||
import ast
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict
|
||||
from dataclasses import asdict, fields
|
||||
from datetime import datetime
|
||||
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 sdk.models import DealItem
|
||||
from sdk.models import Assets, DealItem, OrderItem, PositionItem
|
||||
from sdk.portfolio import PortfolioMixin
|
||||
|
||||
|
||||
class DealModelTests(unittest.TestCase):
|
||||
class ApiModelTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.raw = {
|
||||
'm_strOrderSysID': 'sys-123',
|
||||
'm_strInstrumentID': '600000',
|
||||
'm_strExchangeID': 'SH',
|
||||
'm_strInstrumentName': 'Test stock',
|
||||
'm_nOffsetFlag': '24',
|
||||
'm_nOrderStatus': '56',
|
||||
'm_nVolumeTotal': '0',
|
||||
'm_nVolumeTraded': '200',
|
||||
'm_nOrderTime': '101530',
|
||||
'm_strInsertDate': '20260907',
|
||||
'm_strInsertTime': '10:15:30',
|
||||
'm_strRemark': 'zt-t-sell-local1|zt',
|
||||
'm_dPrice': '15.8',
|
||||
'm_dTradePrice': '15.6',
|
||||
'm_dTradeAmount': '3120',
|
||||
}
|
||||
source = Path(__file__).resolve().parents[2] / 'api' / 'qmt_rest_new.py'
|
||||
names = {'format_assets', 'format_holding', 'format_orders', 'format_deals'}
|
||||
nodes = [n for n in ast.parse(source.read_text(encoding='utf-8')).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name in names]
|
||||
ns = {'HTTPError': RuntimeError}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), 'exec'), ns)
|
||||
attrs = {n.attr: '' if n.attr.startswith('m_str') else 0
|
||||
for node in nodes for n in ast.walk(node)
|
||||
if isinstance(n, ast.Attribute) and n.attr.startswith('m_')}
|
||||
attrs.update(m_strInstrumentID='600000', m_strExchangeID='SH',
|
||||
m_strOrderSysID='sys1', m_strRemark='trend-BUY-1|trend',
|
||||
m_nOffsetFlag=23, m_nOrderStatus=56, m_nVolume=100,
|
||||
m_nVolumeTraded=100, m_nVolumeTotalOriginal=100,
|
||||
m_dPrice=10.0, m_dTradeAmount=1000.0, m_dBalance=2000.0,
|
||||
m_dAvailable=1000.0, m_strInsertDate='20260907',
|
||||
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):
|
||||
self.assertEqual(asdict(DealItem.from_trade_detail(self.raw)), {
|
||||
'sys_order_id': 'sys-123', 'local_order_id': 'zt-t-sell-local1',
|
||||
'code': '600000.SH', 'instrument_id': '600000', 'exchange_id': 'SH',
|
||||
'name': 'Test stock', 'offset_flag': '24', 'side': 'SELL', 'status': '56',
|
||||
'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_models_exactly_match_api_keys_and_values(self):
|
||||
for model, row in ((Assets, self.assets), (PositionItem, self.positions['600000.SH']),
|
||||
(OrderItem, self.orders[0]), (DealItem, self.deals[0])):
|
||||
self.assertEqual({field.name for field in fields(model)}, set(row))
|
||||
self.assertEqual(asdict(model(**row)), row)
|
||||
|
||||
def test_api_deals_response_returns_deal_items(self):
|
||||
client = PortfolioMixin()
|
||||
for response in ({'deals': [self.raw]}, [self.raw]):
|
||||
with self.subTest(response_type=type(response).__name__):
|
||||
client._get_json = lambda path: response
|
||||
deals = client.deals()
|
||||
self.assertIsInstance(deals[0], DealItem)
|
||||
self.assertEqual(deals[0].sys_order_id, 'sys-123')
|
||||
self.assertEqual(deals[0].traded_volume, 200)
|
||||
def test_all_endpoints(self):
|
||||
self.assertEqual(asdict(self.client.assets()), self.assets)
|
||||
codes, positions = self.client.positions()
|
||||
self.assertEqual(codes, ['600000.SH'])
|
||||
self.assertEqual(asdict(positions[0]), self.positions[codes[0]])
|
||||
self.assertEqual(asdict(self.client.orders()[0]), self.orders[0])
|
||||
self.assertEqual(asdict(self.client.deals()[0]), self.deals[0])
|
||||
portfolio = self.client.portfolio()
|
||||
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):
|
||||
deal = DealItem.from_trade_detail(self.raw)
|
||||
def test_derived_properties_and_order_cache(self):
|
||||
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:
|
||||
path = Path(tmp) / 'state.db'
|
||||
book = OrderBook(path)
|
||||
book.sync_deals([deal])
|
||||
with closing(sqlite3.connect(path)) as db:
|
||||
columns = {row[1] for row in db.execute('PRAGMA table_info(deals)')}
|
||||
self.assertEqual(columns, {'id', *asdict(deal)})
|
||||
loaded = OrderBook(path).deals['sys-123']
|
||||
expected = asdict(deal)
|
||||
expected['insert_date'] = '2026-09-07'
|
||||
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
|
||||
loaded = OrderBook(path).deals['sys1']
|
||||
self.assertEqual(loaded['volume'], deal.volume)
|
||||
self.assertEqual(loaded['trade_date'], '2026-09-07')
|
||||
deal.trade_amount = 0
|
||||
self.assertEqual(OrderBook.deal_record(deal)['trade_amount'], 1000)
|
||||
deal.price = 0
|
||||
with self.assertRaises(ValueError):
|
||||
OrderBook.deal_record(deal)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict, fields
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
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'):
|
||||
prefix = {'base': 'zt-base-', 'sell': 'zt-t-sell-', 'buy': 'zt-t-buy-'}[kind]
|
||||
return DealItem.from_trade_detail({
|
||||
'm_strOrderSysID': sys_order_id,
|
||||
'm_strInstrumentID': '600000', 'm_strExchangeID': 'SH',
|
||||
'm_strInstrumentName': 'Test stock',
|
||||
'm_nOffsetFlag': '24' if kind == 'sell' else '23',
|
||||
'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),
|
||||
})
|
||||
return DealItem(
|
||||
order_sys_id=sys_order_id, stock_code='600000.SH',
|
||||
offset_flag=24 if kind == 'sell' else 23,
|
||||
volume=qty, price=price, trade_amount=qty * price,
|
||||
trade_date=date, trade_time='10:00:00', remark=prefix + 'order1|zt',
|
||||
)
|
||||
|
||||
def test_partial_fills_restart_dedup_and_daily_cycle(self):
|
||||
state = TState(self.path)
|
||||
@@ -83,10 +78,12 @@ class OrderBookTests(unittest.TestCase):
|
||||
insert.assert_called_once()
|
||||
self.assertEqual(len(insert.call_args.args[1]), 2)
|
||||
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
|
||||
self.assertEqual(book.deals['d1']['insert_date'], '2026-09-01')
|
||||
self.assertEqual(book.deals['d2']['traded_volume'], 60)
|
||||
self.assertEqual(book.deals['d1']['trade_date'], '2026-09-01')
|
||||
self.assertEqual(book.deals['d2']['volume'], 60)
|
||||
self.assertEqual(book.deals['d2']['order_local_id'], 'zt-base-order1')
|
||||
book = OrderBook(self.path)
|
||||
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:
|
||||
book.sync_deals([first, second])
|
||||
book.sync_deals([])
|
||||
@@ -97,25 +94,25 @@ class OrderBookTests(unittest.TestCase):
|
||||
book = OrderBook(self.path)
|
||||
first = self.deal('base', 'd1', 100, 10)
|
||||
invalid = self.deal('base', 'd2', 100, 10)
|
||||
invalid.side = 'INVALID'
|
||||
invalid.offset_flag = -1
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
book.sync_deals([first, invalid])
|
||||
self.assertEqual(book.deals, {})
|
||||
self.assertEqual(book.deals_sys_ids, set())
|
||||
self.assertEqual(OrderBook(self.path).deals, {})
|
||||
invalid.side = 'BUY'
|
||||
invalid.offset_flag = 23
|
||||
book.sync_deals([first, invalid])
|
||||
self.assertEqual(book.deals_sys_ids, {'d1', 'd2'})
|
||||
|
||||
def test_load_refreshes_all_caches(self):
|
||||
book = 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)])
|
||||
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['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):
|
||||
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'")}
|
||||
self.assertEqual(tables, {'positions', 'deals', 'sqlite_sequence'})
|
||||
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.assertTrue({'sys_order_id', 'local_order_id'} <= columns)
|
||||
self.assertEqual(columns, {'id', 'order_local_id', *(field.name for field in fields(DealItem))})
|
||||
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',
|
||||
'idx_positions_added_order_id', 'idx_deals_local_order_id',
|
||||
'idx_deals_code_date', 'idx_deals_date_time'} <= indexes)
|
||||
self.assertTrue({'idx_positions_stock_code', 'idx_deals_order_sys_id',
|
||||
'idx_deals_order_ref', 'idx_deals_stock_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.assertEqual(TState(self.path).deals, state.deals)
|
||||
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):
|
||||
store = OrderBook(self.path)
|
||||
with closing(sqlite3.connect(self.path)) as db:
|
||||
columns = [row[1] for row in db.execute('PRAGMA table_info(positions)')]
|
||||
self.assertEqual(columns, [
|
||||
'id', 'code', 'base_order_id', 'base_qty', 'base_cost',
|
||||
'added_order_id', 'added_num', 'added_qty', 'added_cost', 'status',
|
||||
])
|
||||
columns = {row[1] for row in db.execute('PRAGMA table_info(positions)')}
|
||||
self.assertEqual(columns, {'id', *(field.name for field in fields(PositionItem))})
|
||||
indexes = {row[1] for row in db.execute('PRAGMA index_list(positions)')}
|
||||
self.assertEqual(indexes, {
|
||||
'idx_positions_code', 'idx_positions_base_order_id', 'idx_positions_added_order_id',
|
||||
})
|
||||
store.save({'600000.SH': {'code': '600000.SH', 'status': READY}}, [])
|
||||
position = store.positions['600000.SH']
|
||||
first_id = position['id']
|
||||
self.assertGreater(first_id, 0)
|
||||
self.assertEqual(position['base_order_id'], '')
|
||||
self.assertEqual(position['base_qty'], 0)
|
||||
self.assertEqual(position['added_cost'], 0.0)
|
||||
position.update(base_order_id='base1', base_qty=200, base_cost=10.5,
|
||||
added_order_id='add1', added_num=1, added_qty=100, added_cost=9.0,
|
||||
status='ACTIVE')
|
||||
store.save({'600000.SH': position}, [])
|
||||
self.assertEqual(store.positions['600000.SH'], position)
|
||||
store.save({}, [])
|
||||
store.save({'600001.SH': {'code': '600001.SH', 'status': READY}}, [])
|
||||
self.assertEqual(indexes, {'idx_positions_stock_code'})
|
||||
position = PositionItem(stock_code='600000.SH', volume=100, open_price=10,
|
||||
stock_name='stock', can_use_volume=100, float_profit=-2.5)
|
||||
store.sync_positions([position])
|
||||
saved = store.positions[position.stock_code]
|
||||
first_id = saved['id']
|
||||
self.assertEqual({k: v for k, v in saved.items() if k != 'id'}, asdict(position))
|
||||
position.volume = 200
|
||||
store.sync_positions([position])
|
||||
self.assertEqual(store.positions[position.stock_code]['id'], first_id)
|
||||
self.assertEqual(store.positions[position.stock_code]['volume'], 200)
|
||||
store.sync_positions([])
|
||||
self.assertEqual(store.positions, {})
|
||||
store.sync_positions([PositionItem(stock_code='600001.SH')])
|
||||
self.assertGreater(store.positions['600001.SH']['id'], first_id)
|
||||
|
||||
def test_base_split_fills_and_snapshot_do_not_double_count(self):
|
||||
@@ -207,12 +203,12 @@ class OrderBookTests(unittest.TestCase):
|
||||
state = TState(self.path)
|
||||
first = self.deal('base', 'd1', 100, 10, '20260901')
|
||||
other = self.deal('base', 'd2', 100, 10)
|
||||
other.local_order_id = 'trend-base-order'
|
||||
other.remark = 'trend-base-order'
|
||||
state.reconcile([], [first, other])
|
||||
first.insert_date = '2026-09-01'
|
||||
first.trade_date = '2026-09-01'
|
||||
state.reconcile([], [first])
|
||||
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__':
|
||||
|
||||
88
py-client/tests/test_trend_collector.py
Normal file
88
py-client/tests/test_trend_collector.py
Normal 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()
|
||||
Reference in New Issue
Block a user