diff --git a/PKG-INFO b/PKG-INFO index 14c0ceaa..8093cf50 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: tqsdk -Version: 3.1.1 +Version: 3.2.0 Summary: TianQin SDK Home-page: https://www.shinnytech.com/tqsdk Author: TianQin diff --git a/doc/advanced/for_vnpy_user.rst b/doc/advanced/for_vnpy_user.rst index 5cbe7432..6aec6217 100644 --- a/doc/advanced/for_vnpy_user.rst +++ b/doc/advanced/for_vnpy_user.rst @@ -227,14 +227,15 @@ TqSdk针对行情数据和交易信息都采用相同的 wait_update/is_changing 图形界面 ------------------------------------------------- -TqSdk 本身并不包含任何图形界面. 这部分功能由天勤软件提供支持: +TqSdk 提供 :ref:`web_gui` 来供有图形化需求的用户使用: -* 策略运行时, 提供交易记录/日志的监控表格. 交易记录和持仓记录自动在行情图上标记, 可以快速定位跳转, 可以跨周期缩放定位 -* 策略回测时, 提供回测报告/图上标记. +* 策略运行时, 交易记录和持仓记录自动在行情图上标记, 可以快速定位跳转, 可以跨周期缩放定位 +* 策略回测时, 提供回测报告/图上标记和对应的回测分析报告. * 策略运行和回测信息自动保存, 可事后随时查阅显示 -TqSdk配合天勤使用时, 还支持自定义绘制行情图表, 像这样:: +TqSdk配合web_gui使用时, 还支持自定义绘制行情图表, 像这样:: + api = TqApi(auth=TqAuth("信易账户","账户密码"), web_gui=True) # 获取 cu1905 和 cu1906 的日线数据 klines = api.get_kline_serial("SHFE.cu1905", 86400) klines2 = api.get_kline_serial("SHFE.cu1906", 86400) @@ -263,7 +264,7 @@ TqSdk配合天勤使用时, 还支持自定义绘制行情图表, 像这样:: ------------------------------------------------- 此外, 还有一些差别值得注意 -* TqSdk 要求 Python 3.6 以上版本, 不支持 Python 2.x +* TqSdk 要求 Python 3.6.4 以上版本, 不支持 Python 2.x * TqSdk 使用了Python3的async框架, 某些 IDE 不支持, 需要使用支持 async 的IDE, 例如 pycharm 要学习使用 TqSdk, 推荐从 :ref:`quickstart` 开始 diff --git a/doc/conf.py b/doc/conf.py index c8bf9ded..72a97a38 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = u'3.1.1' +version = u'3.2.0' # The full version, including alpha/beta/rc tags. -release = u'3.1.1' +release = u'3.2.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/reference/tqsdk.sim.rst b/doc/reference/tqsdk.sim.rst index 9ecbac25..366d78e0 100644 --- a/doc/reference/tqsdk.sim.rst +++ b/doc/reference/tqsdk.sim.rst @@ -1,7 +1,16 @@ .. _tqsdk.sim: -tqsdk.sim - 本地模拟交易 +tqsdk.tqsim - 本地模拟交易 ------------------------------------------------------------------ -.. automodule:: tqsdk.tradeable.sim.tqsim +.. autoclass:: tqsdk.tradeable.sim.tqsim.TqSim + :members: + :inherited-members: + + +.. _tqsdk.sim_stock: + +tqsdk.tqsim_stock - 本地股票模拟交易 +------------------------------------------------------------------ +.. autoclass:: tqsdk.tradeable.sim.tqsim_stock.TqSimStock :members: :inherited-members: diff --git a/doc/version.rst b/doc/version.rst index 7f578e8f..350090b7 100644 --- a/doc/version.rst +++ b/doc/version.rst @@ -2,6 +2,14 @@ 版本变更 ============================= +3.2.0 (2021/12/31) + +* 新增::py:class:`~tqsdk.tradeable.sim.tqsim_stock.TqSimStock` 类实现本地股票模拟交易,同时支持在实盘/回测模式下使用。 + **专业版用户** 可用,专业版购买网址:https://account.shinnytech.com。 +* web_gui:修复回测时不能正常显示结果报告的问题 +* 修复:windows 下调用 :py:meth:`~tqsdk.api.TqApi.get_kline_data_series` 时,可能出现缓存文件不允许重复重命的问题 + + 3.1.1 (2021/12/24) * 修复:穿管采集文件读取失败 diff --git a/setup.py b/setup.py index c734d5d8..9293b110 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ def get_tag(self): setuptools.setup( name='tqsdk', - version="3.1.1", + version="3.2.0", description='TianQin SDK', author='TianQin', author_email='tianqincn@gmail.com', diff --git a/tqsdk/__init__.py b/tqsdk/__init__.py index 1ffcdb44..1b6e4bc9 100644 --- a/tqsdk/__init__.py +++ b/tqsdk/__init__.py @@ -4,7 +4,7 @@ name = "tqsdk" from tqsdk.api import TqApi -from tqsdk.tradeable import TqAccount, TqKq, TqKqStock, TqSim +from tqsdk.tradeable import TqAccount, TqKq, TqKqStock, TqSim, TqSimStock from tqsdk.auth import TqAuth from tqsdk.channel import TqChan from tqsdk.backtest import TqBacktest, TqReplay diff --git a/tqsdk/__version__.py b/tqsdk/__version__.py index 726691bc..573cf70b 100644 --- a/tqsdk/__version__.py +++ b/tqsdk/__version__.py @@ -1 +1 @@ -__version__ = '3.1.1' +__version__ = '3.2.0' diff --git a/tqsdk/api.py b/tqsdk/api.py index a91bcc53..0aa9c6d3 100644 --- a/tqsdk/api.py +++ b/tqsdk/api.py @@ -78,7 +78,7 @@ from tqsdk.risk_rule import TqRiskRule from tqsdk.ins_schema import ins_schema, basic, derivative, future, option from tqsdk.symbols import TqSymbols -from tqsdk.tradeable import TqAccount, TqKq, TqKqStock, TqSim, BaseOtg +from tqsdk.tradeable import TqAccount, TqKq, TqKqStock, TqSim, TqSimStock, BaseOtg from tqsdk.trading_status import TqTradingStatus from tqsdk.tqwebhelper import TqWebHelper from tqsdk.utils import _generate_uuid, _query_for_quote, BlockManagerUnconsolidated, _quotes_add_night, _bisect_value @@ -94,8 +94,9 @@ class TqApi(TqBaseApi): 通常情况下, 一个线程中 **应该只有一个** TqApi的实例, 它负责维护网络连接, 接收行情及账户数据, 并在内存中维护业务数据截面 """ - def __init__(self, account: Union[TqMultiAccount, TqAccount, TqKq, TqKqStock, TqSim, None] = None, auth: Union[TqAuth, str, None] = None, url: Optional[str] = None, - backtest: Union[TqBacktest, TqReplay, None] = None, web_gui: [bool, str] = False, debug: Union[bool, str, None] = False, + def __init__(self, account: Union[TqMultiAccount, TqAccount, TqKq, TqKqStock, TqSim, TqSimStock, None] = None, + auth: Union[TqAuth, str, None] = None, url: Optional[str] = None, + backtest: Union[TqBacktest, TqReplay, None] = None, web_gui: Union[bool, str] = False, debug: Union[bool, str, None] = False, loop: Optional[asyncio.AbstractEventLoop] = None, disable_print: bool = False, _stock: bool = True, _ins_url=None, _md_url=None, _td_url=None) -> None: """ @@ -1028,7 +1029,7 @@ def get_trading_calendar(self, start_dt: Union[date, datetime], end_dt: Union[da first_date, latest_date = _init_chinese_rest_days() if start_dt < first_date or end_dt > latest_date: raise Exception(f"交易日历可以处理的范围为 {first_date.strftime('%Y-%m-%d')} ~ {latest_date.strftime('%Y-%m-%d')},请修改参数") - return _get_trading_calendar(start_dt, end_dt) + return _get_trading_calendar(start_dt, end_dt, headers=self._base_headers) # ---------------------------------------------------------------------- def query_his_cont_quotes(self, symbol: Union[str, List[str]], n: int = 200): @@ -1081,7 +1082,8 @@ def query_his_cont_quotes(self, symbol: Union[str, List[str]], n: int = 200): now_dt = self._get_current_datetime() trading_day = _get_trading_day_from_timestamp(int(now_dt.timestamp() * 1000000000)) end_dt = datetime.fromtimestamp(trading_day / 1000000000) - cont_calendar = TqContCalendar(start_dt=end_dt - timedelta(days=n * 2 + 30), end_dt=end_dt, symbols=symbols) + cont_calendar = TqContCalendar(start_dt=end_dt - timedelta(days=n * 2 + 30), end_dt=end_dt, symbols=symbols, + headers=self._base_headers) df = cont_calendar.df.loc[cont_calendar.df.date.le(end_dt), ['date'] + symbols] df = df.iloc[-n:] df.reset_index(inplace=True, drop=True) @@ -3124,7 +3126,10 @@ def _setup_connection(self): self._auth._add_account(acc._account_id) elif isinstance(acc, TqKqStock): if not self._auth._has_account(acc._account_id): - raise Exception(f"您的账户不支持快期股票模拟,需要购买专业版本后使用。升级网址:https://account.shinnytech.com") + raise Exception(f"您的账户不支持快期股票模拟 TqKqStock,需要购买专业版本后使用。升级网址:https://account.shinnytech.com") + elif isinstance(acc, TqSimStock): + if not self._auth._has_feature("sec"): + raise Exception(f"您的账户不支持本地股票模拟 TqSimStock,需要购买专业版本后使用。升级网址:https://account.shinnytech.com") # 等待复盘服务器启动 if isinstance(self._backtest, TqReplay): diff --git a/tqsdk/backtest/__init__.py b/tqsdk/backtest/__init__.py new file mode 100644 index 00000000..844369c8 --- /dev/null +++ b/tqsdk/backtest/__init__.py @@ -0,0 +1,7 @@ +#!usr/bin/env python3 +# -*- coding:utf-8 -*- + +__author__ = 'mayanqiong' + +from tqsdk.backtest.backtest import TqBacktest +from tqsdk.backtest.replay import TqReplay \ No newline at end of file diff --git a/tqsdk/backtest/backtest.py b/tqsdk/backtest/backtest.py new file mode 100644 index 00000000..3bfffd5c --- /dev/null +++ b/tqsdk/backtest/backtest.py @@ -0,0 +1,735 @@ +#!usr/bin/env python3 +# -*- coding:utf-8 -*- + +__author__ = 'mayanqiong' + + +import asyncio +import math +from datetime import date, datetime +from typing import Union, Any + +from tqsdk.backtest.utils import TqBacktestContinuous, TqBacktestDividend +from tqsdk.channel import TqChan +from tqsdk.datetime import _get_trading_day_start_time, _get_trading_day_end_time, _get_trading_day_from_timestamp +from tqsdk.diff import _merge_diff, _get_obj +from tqsdk.entity import Entity +from tqsdk.exceptions import BacktestFinished +from tqsdk.objs import Kline, Tick +from tqsdk.rangeset import _rangeset_range_union, _rangeset_difference, _rangeset_union +from tqsdk.utils import _generate_uuid, _query_for_quote + + +class BtQuote(Entity): + """ Quote 是一个行情对象 """ + def __init__(self, api): + self._api = api + self.price_tick: float = float("nan") + + +class TqBacktest(object): + """ + 天勤回测类 + + 将该类传入 TqApi 的构造函数, 则策略就会进入回测模式。 + + 回测模式下 k线会在刚创建出来时和结束时分别更新一次, 在这之间 k线是不会更新的。 + + 回测模式下 quote 的更新频率由所订阅的 tick 和 k线周期确定: + * 只要订阅了 tick, 则对应合约的 quote 就会使用 tick 生成, 更新频率也和 tick 一致, 但 **只有下字段** : + datetime/ask&bid_price1/ask&bid_volume1/last_price/highest/lowest/average/volume/amount/open_interest/ + price_tick/price_decs/volume_multiple/max&min_limit&market_order_volume/underlying_symbol/strike_price + + * 如果没有订阅 tick, 但是订阅了 k线, 则对应合约的 quote 会使用 k线生成, 更新频率和 k线的周期一致, 如果订阅了某个合约的多个周期的 k线, + 则任一个周期的 k线有更新时, quote 都会更新. 使用 k线生成的 quote 的盘口由收盘价分别加/减一个最小变动单位, 并且 highest/lowest/average/amount + 始终为 nan, volume 始终为0 + + * 如果即没有订阅 tick, 也没有订阅k线或 订阅的k线周期大于分钟线, 则 TqBacktest 会 **自动订阅分钟线** 来生成 quote + + * 如果没有订阅 tick, 但是订阅了 k线, 则对应合约的 quote **只有下字段** : + datetime/ask&bid_price1/ask&bid_volume1/last_price/open_interest/ + price_tick/price_decs/volume_multiple/max&min_limit&market_order_volume/underlying_symbol/strike_price + + **注意** :如果未订阅 quote,模拟交易在下单时会自动为此合约订阅 quote ,根据回测时 quote 的更新规则,如果此合约没有订阅K线或K线周期大于分钟线 **则会自动订阅一个分钟线** 。 + + 模拟交易要求报单价格大于等于对手盘价格才会成交, 例如下买单, 要求价格大于等于卖一价才会成交, 如果不能立即成交则会等到下次行情更新再重新判断。 + + 回测模式下 wait_update 每次最多推进一个行情时间。 + + 回测结束后会抛出 BacktestFinished 例外。 + + 对 **组合合约** 进行回测时需注意:只能通过订阅 tick 数据来回测,不能订阅K线,因为K线是由最新价合成的,而交易所发回的组合合约数据中无最新价。 + """ + + def __init__(self, start_dt: Union[date, datetime], end_dt: Union[date, datetime]) -> None: + """ + 创建天勤回测类 + + Args: + start_dt (date/datetime): 回测起始时间, 如果类型为 date 则指的是交易日, 如果为 datetime 则指的是具体时间点 + + end_dt (date/datetime): 回测结束时间, 如果类型为 date 则指的是交易日, 如果为 datetime 则指的是具体时间点 + """ + if isinstance(start_dt, datetime): + self._start_dt = int(start_dt.timestamp() * 1e9) + elif isinstance(start_dt, date): + self._start_dt = _get_trading_day_start_time( + int(datetime(start_dt.year, start_dt.month, start_dt.day).timestamp()) * 1000000000) + else: + raise Exception("回测起始时间(start_dt)类型 %s 错误, 请检查 start_dt 数据类型是否填写正确" % (type(start_dt))) + if isinstance(end_dt, datetime): + self._end_dt = int(end_dt.timestamp() * 1e9) + elif isinstance(end_dt, date): + self._end_dt = _get_trading_day_end_time( + int(datetime(end_dt.year, end_dt.month, end_dt.day).timestamp()) * 1000000000) + else: + raise Exception("回测结束时间(end_dt)类型 %s 错误, 请检查 end_dt 数据类型是否填写正确" % (type(end_dt))) + self._current_dt = self._start_dt + # 记录当前的交易日 开始时间/结束时间 + self._trading_day = _get_trading_day_from_timestamp(self._current_dt) + self._trading_day_start = _get_trading_day_start_time(self._trading_day) + self._trading_day_end = _get_trading_day_end_time(self._trading_day) + + async def _run(self, api, sim_send_chan, sim_recv_chan, md_send_chan, md_recv_chan): + """回测task""" + self._api = api + # 下载历史主连合约信息 + start_trading_day = _get_trading_day_from_timestamp(self._start_dt) # 回测开始交易日 + end_trading_day = _get_trading_day_from_timestamp(self._end_dt) # 回测结束交易日 + self._continuous_table = TqBacktestContinuous(start_dt=start_trading_day, + end_dt=end_trading_day, + headers=self._api._base_headers) + self._stock_dividend = TqBacktestDividend(start_dt=start_trading_day, + end_dt=end_trading_day, + headers=self._api._base_headers) + self._logger = api._logger.getChild("TqBacktest") # 调试信息输出 + self._sim_send_chan = sim_send_chan + self._sim_recv_chan = sim_recv_chan + self._md_send_chan = md_send_chan + self._md_recv_chan = md_recv_chan + self._pending_peek = False + self._data = Entity() # 数据存储 + self._data._instance_entity([]) + self._prototype = { + "quotes": { + "#": BtQuote(self._api), # 行情的数据原型 + }, + "klines": { + "*": { + "*": { + "data": { + "@": Kline(self._api), # K线的数据原型 + } + } + } + }, + "ticks": { + "*": { + "data": { + "@": Tick(self._api), # Tick的数据原型 + } + } + } + } + self._sended_to_api = {} # 已经发给 api 的 rangeset (symbol, dur),只记录了 kline + self._serials = {} # 所有用户请求的 chart 序列,如果用户订阅行情,默认请求 1 分钟 Kline + # gc 是会循环 self._serials,来计算用户需要的数据,self._serials 不应该被删除, + self._generators = {} # 所有用户请求的 chart 序列相应的 generator 对象,创建时与 self._serials 一一对应,会在一个序列计算到最后一根 kline 时被删除 + self._had_any_generator = False # 回测过程中是否有过 generator 对象 + self._sim_recv_chan_send_count = 0 # 统计向下游发送的 diff 的次数,每 1w 次执行一次 gc + self._quotes = {} # 记录 min_duration 记录某一合约的最小duration; sended_init_quote 是否已经过这个合约的初始行情 + self._diffs: list[dict[str, Any]] = [] + self._is_first_send = True + md_task = self._api.create_task(self._md_handler()) + try: + await self._send_snapshot() + async for pack in self._sim_send_chan: + if pack["aid"] == "ins_query": + await self._md_send_chan.send(pack) + # 回测 query 不为空时需要ensure_query + # 1. 在api初始化时会发送初始化请求(2.5.0版本开始已经不再发送初始化请求),接着会发送peek_message,如果这里没有等到结果,那么在收到 peek_message 的时候,会发现没有数据需要发送,回测结束 + # 2. api在发送请求后,会调用 wait_update 更新数据,如果这里没有等到结果,行情可能会被推进 + # query 为空时,表示清空数据的请求,这个可以直接发出去,不需要等到收到回复 + if pack["query"] != "": + await self._ensure_query(pack) + await self._send_diff() + elif pack["aid"] == "subscribe_quote": + # todo: 回测时,用户如果先订阅日线,再订阅行情,会直接返回以日线 datetime 标识的行情信息,而不是当前真正的行情时间 + self._diffs.append({ + "ins_list": pack["ins_list"] + }) + for ins in pack["ins_list"].split(","): + await self._ensure_quote(ins) + await self._send_diff() # 处理上一次未处理的 peek_message + elif pack["aid"] == "set_chart": + if pack["ins_list"]: + # 回测模块中已保证每次将一个行情时间的数据全部发送给api,因此更新行情时 保持与初始化时一样的charts信息(即不作修改) + self._diffs.append({ + "charts": { + pack["chart_id"]: { + # 两个id设置为0:保证api在回测中判断此值时不是-1,即直接通过对数据接收完全的验证 + "left_id": 0, + "right_id": 0, + "more_data": False, # 直接发送False给api,表明数据发送完全,使api中通过数据接收完全的验证 + "state": pack + } + } + }) + await self._ensure_serial(pack["ins_list"], pack["duration"], pack["chart_id"]) + else: + self._diffs.append({ + "charts": { + pack["chart_id"]: None + } + }) + await self._send_diff() # 处理上一次未处理的 peek_message + elif pack["aid"] == "peek_message": + self._pending_peek = True + await self._send_diff() + finally: + # 关闭所有 generator + for s in self._generators.values(): + await s.aclose() + md_task.cancel() + await asyncio.gather(md_task, return_exceptions=True) + + async def _md_handler(self): + async for pack in self._md_recv_chan: + await self._md_send_chan.send({ + "aid": "peek_message" + }) + recv_quotes = False + for d in pack.get("data", []): + _merge_diff(self._data, d, self._prototype, False) + # 收到的 quotes 转发给下游 + quotes = d.get("quotes", {}) + if quotes: + recv_quotes = True + quotes = self._update_valid_quotes(quotes) # 删去回测 quotes 不应该下发的字段 + self._diffs.append({"quotes": quotes}) + # 收到的 symbols 应该转发给下游 + if d.get("symbols"): + self._diffs.append({"symbols": d["symbols"]}) + # 如果没有收到 quotes(合约信息),或者当前的 self._data.get('quotes', {}) 里没有股票,那么不应该向 _diffs 里添加元素 + if recv_quotes: + quotes_stock = self._stock_dividend._get_dividend(self._data.get('quotes', {}), self._trading_day) + if quotes_stock: + self._diffs.append({"quotes": quotes_stock}) + + def _update_valid_quotes(self, quotes): + # 从 quotes 返回只剩余合约信息的字段的 quotes,防止发生未来数据发送给下游 + # backtest 模块会生成的数据 + invalid_keys = {f"{d}{i+1}" for d in ['ask_price', 'ask_volume', 'bid_price', 'bid_volume'] for i in range(5)} + invalid_keys.union({'datetime', 'last_price', 'highest', 'lowest', 'average', 'volume', 'amount', 'open_interest'}) + invalid_keys.union({'cash_dividend_ratio', 'stock_dividend_ratio'}) # 这两个字段完全由 self._stock_dividend 负责处理 + # backtest 模块不会生成的数据,下游服务也不应该收到的数据 + invalid_keys.union({'open', 'close', 'settlement', 'lowest', 'lower_limit', 'upper_limit', 'pre_open_interest', 'pre_settlement', 'pre_close', 'expired'}) + for symbol, quote in quotes.items(): + [quote.pop(k, None) for k in invalid_keys] + if symbol.startswith("KQ.m"): + quote.pop("underlying_symbol", None) + if quote.get('expire_datetime'): + # 先删除所有的 quote 的 expired 字段,只在有 expire_datetime 字段时才会添加 expired 字段 + quote['expired'] = quote.get('expire_datetime') * 1e9 <= self._trading_day_start + return quotes + + async def _send_snapshot(self): + """发送初始合约信息""" + async with TqChan(self._api, last_only=True) as update_chan: # 等待与行情服务器连接成功 + self._data["_listener"].add(update_chan) + while self._data.get("mdhis_more_data", True): + await update_chan.recv() + # 发送初始行情(合约信息截面)时 + quotes = {} + for ins, quote in self._data["quotes"].items(): + if not ins.startswith("_"): + trading_time = quote.get("trading_time", {}) + quotes[ins] = { + "open": None, # 填写None: 删除api中的这个字段 + "close": None, + "settlement": None, + "lower_limit": None, + "upper_limit": None, + "pre_open_interest": None, + "pre_settlement": None, + "pre_close": None, + "ins_class": quote.get("ins_class", ""), + "instrument_id": quote.get("instrument_id", ""), + "exchange_id": quote.get("exchange_id", ""), + "margin": quote.get("margin"), # 用于内部实现模拟交易, 不作为api对外可用数据(即 Quote 类中无此字段) + "commission": quote.get("commission"), # 用于内部实现模拟交易, 不作为api对外可用数据(即 Quote 类中无此字段) + "price_tick": quote["price_tick"], + "price_decs": quote["price_decs"], + "volume_multiple": quote["volume_multiple"], + "max_limit_order_volume": quote["max_limit_order_volume"], + "max_market_order_volume": quote["max_market_order_volume"], + "min_limit_order_volume": quote["min_limit_order_volume"], + "min_market_order_volume": quote["min_market_order_volume"], + "underlying_symbol": quote["underlying_symbol"], + "strike_price": quote["strike_price"], + "expired": quote.get('expire_datetime', float('nan')) <= self._trading_day_start, # expired 默认值就是 False + "trading_time": {"day": trading_time.get("day", []), "night": trading_time.get("night", [])}, + "expire_datetime": quote.get("expire_datetime"), + "delivery_month": quote.get("delivery_month"), + "delivery_year": quote.get("delivery_year"), + "option_class": quote.get("option_class", ""), + "product_id": quote.get("product_id", ""), + } + # 修改历史主连合约信息 + cont_quotes = self._continuous_table._get_history_cont_quotes(self._trading_day) + for k, v in cont_quotes.items(): + quotes.setdefault(k, {}) # 实际上,初始行情截面中只有下市合约,没有主连 + quotes[k].update(v) + self._diffs.append({ + "quotes": quotes, + "ins_list": "", + "mdhis_more_data": False, + "_tqsdk_backtest": self._get_backtest_time() + }) + + async def _send_diff(self): + """发送数据到 api, 如果 self._diffs 不为空则发送 self._diffs, 不推进行情时间, 否则将时间推进一格, 并发送对应的行情""" + if self._pending_peek: + if not self._diffs: + quotes = await self._generator_diffs(False) + else: + quotes = await self._generator_diffs(True) + for ins, diff in quotes.items(): + self._quotes[ins]["sended_init_quote"] = True + for d in diff: + self._diffs.append({ + "quotes": { + ins: d + } + }) + if self._diffs: + # 发送数据集中添加 backtest 字段,开始时间、结束时间、当前时间,表示当前行情推进是由 backtest 推进 + self._diffs.append({"_tqsdk_backtest": self._get_backtest_time()}) + + # 切换交易日,将历史的主连合约信息添加的 diffs + if self._current_dt > self._trading_day_end: + # 使用交易日结束时间,每个交易日切换只需要计算一次交易日结束时间 + # 相比发送 diffs 前每次都用 _current_dt 计算当前交易日,计算次数更少 + self._trading_day = _get_trading_day_from_timestamp(self._current_dt) + self._trading_day_start = _get_trading_day_start_time(self._trading_day) + self._trading_day_end = _get_trading_day_end_time(self._trading_day) + self._diffs.append({ + "quotes": self._continuous_table._get_history_cont_quotes(self._trading_day) + }) + self._diffs.append({ + "quotes": self._stock_dividend._get_dividend(self._data.get('quotes'), self._trading_day) + }) + self._diffs.append({ + "quotes": {k: {'expired': v.get('expire_datetime', float('nan')) <= self._trading_day_start} + for k, v in self._data.get('quotes').items()} + }) + + self._sim_recv_chan_send_count += 1 + if self._sim_recv_chan_send_count > 10000: + self._sim_recv_chan_send_count = 0 + self._diffs.append(self._gc_data()) + rtn_data = { + "aid": "rtn_data", + "data": self._diffs, + } + self._diffs = [] + self._pending_peek = False + await self._sim_recv_chan.send(rtn_data) + + async def _generator_diffs(self, keep_current): + """ + keep_current 为 True 表示不会推进行情,为 False 表示需要推进行情 + 即 self._diffs 为 None 并且 keep_current = True 会推进行情 + """ + quotes = {} + while self._generators: + # self._generators 存储了 generator,self._serials 记录一些辅助的信息 + min_request_key = min(self._generators.keys(), key=lambda serial: self._serials[serial]["timestamp"]) + timestamp = self._serials[min_request_key]["timestamp"] # 所有已订阅数据中的最小行情时间 + quotes_diff = self._serials[min_request_key]["quotes"] + if timestamp < self._current_dt and self._quotes.get(min_request_key[0], {}).get("sended_init_quote"): + # 先订阅 A 合约,再订阅 A 合约日线,那么 A 合约的行情时间会回退: 2021-01-04 09:31:59.999999 -> 2021-01-01 18:00:00.000000 + # 如果当前 timestamp 小于 _current_dt,那么这个 quote_diff 不需要发到下游 + # 如果先订阅 A 合约(有夜盘),时间停留在夜盘开始时间, 再订阅 B 合约(没有夜盘),那么 B 合约的行情(前一天收盘时间)应该发下去, + # 否则 get_quote(B) 等到收到行情才返回,会直接把时间推进到第二天白盘。 + quotes_diff = None + # 推进时间,一次只会推进最多一个(补数据时有可能是0个)行情时间,并确保<=该行情时间的行情都被发出 + # 如果行情时间大于当前回测时间 则 判断是否diff中已有数据;否则表明此行情时间的数据未全部保存在diff中,则继续append + if timestamp > self._current_dt: + if self._diffs or keep_current: # 如果diffs中已有数据:退出循环并发送数据给下游api + break + else: + self._current_dt = timestamp # 否则将回测时间更新至最新行情时间 + diff = self._serials[min_request_key]["diff"] + self._diffs.append(diff) + # klines 请求,需要记录已经发送 api 的数据 + for symbol in diff.get("klines", {}): + for dur in diff["klines"][symbol]: + for kid in diff["klines"][symbol][dur]["data"]: + rs = self._sended_to_api.setdefault((symbol, int(dur)), []) + kid = int(kid) + self._sended_to_api[(symbol, int(dur))] = _rangeset_range_union(rs, (kid, kid + 1)) + quote_info = self._quotes[min_request_key[0]] + if quotes_diff and (quote_info["min_duration"] != 0 or min_request_key[1] == 0): + quotes[min_request_key[0]] = quotes_diff + await self._fetch_serial(min_request_key) + if self._had_any_generator and not self._generators and not self._diffs: # 当无可发送数据时则抛出BacktestFinished例外,包括未订阅任何行情 或 所有已订阅行情的最后一笔行情获取完成 + self._api._print("回测结束") + self._logger.debug("backtest finished") + if self._current_dt < self._end_dt: + self._current_dt = 2145888000000000000 # 一个远大于 end_dt 的日期 20380101 + await self._sim_recv_chan.send({ + "aid": "rtn_data", + "data": [{"_tqsdk_backtest": self._get_backtest_time()}] + }) + await self._api._wait_until_idle() + raise BacktestFinished(self._api) from None + return quotes + + def _get_backtest_time(self) -> dict: + if self._is_first_send: + self._is_first_send = False + return { + "start_dt": self._start_dt, + "current_dt": self._current_dt, + "end_dt": self._end_dt + } + else: + return { + "current_dt": self._current_dt + } + + async def _ensure_serial(self, ins, dur, chart_id=None): + if (ins, dur) not in self._serials: + quote = self._quotes.setdefault(ins, { # 在此处设置 min_duration: 每次生成K线的时候会自动生成quote, 记录某一合约的最小duration + "min_duration": dur + }) + quote["min_duration"] = min(quote["min_duration"], dur) + self._serials[(ins, dur)] = { + "chart_id_set": {chart_id} if chart_id else set() # 记录当前 serial 对应的 chart_id + } + self._generators[(ins, dur)] = self._gen_serial(ins, dur) + self._had_any_generator = True + await self._fetch_serial((ins, dur)) + elif chart_id: + self._serials[(ins, dur)]["chart_id_set"].add(chart_id) + + async def _ensure_query(self, pack): + """一定收到了对应 query 返回的包""" + query_pack = {"query": pack["query"]} + if query_pack.items() <= self._data.get("symbols", {}).get(pack["query_id"], {}).items(): + return + async with TqChan(self._api, last_only=True) as update_chan: + self._data["_listener"].add(update_chan) + while not query_pack.items() <= self._data.get("symbols", {}).get(pack["query_id"], {}).items(): + await update_chan.recv() + + async def _ensure_quote(self, ins): + # 在接新版合约服务器后,合约信息程序运行过程中查询得到的,这里不再能保证合约一定存在,需要添加 quote 默认值 + quote = _get_obj(self._data, ["quotes", ins], BtQuote(self._api)) + if math.isnan(quote.get("price_tick")): + query_pack = _query_for_quote(ins) + await self._md_send_chan.send(query_pack) + async with TqChan(self._api, last_only=True) as update_chan: + quote["_listener"].add(update_chan) + while math.isnan(quote.get("price_tick")): + await update_chan.recv() + if ins not in self._quotes or self._quotes[ins]["min_duration"] > 60000000000: + await self._ensure_serial(ins, 60000000000) + + async def _fetch_serial(self, key): + s = self._serials[key] + try: + s["timestamp"], s["diff"], s["quotes"] = await self._generators[key].__anext__() + except StopAsyncIteration: + del self._generators[key] # 删除一个行情时间超过结束时间的 generator + + async def _gen_serial(self, ins, dur): + """k线/tick 序列的 async generator, yield 出来的行情数据带有时间戳, 因此 _send_diff 可以据此归并""" + # 先定位左端点, focus_datetime 是 lower_bound ,这里需要的是 upper_bound + # 因此将 view_width 和 focus_position 设置成一样,这样 focus_datetime 所对应的 k线刚好位于屏幕外 + # 使用两个长度为 8964 的 chart,去缓存/回收下游需要的数据 + chart_id_a = _generate_uuid("PYSDK_backtest") + chart_id_b = _generate_uuid("PYSDK_backtest") + chart_info = { + "aid": "set_chart", + "chart_id": chart_id_a, + "ins_list": ins, + "duration": dur, + "view_width": 8964, # 设为8964原因:可满足用户所有的订阅长度,并在backtest中将所有的 相同合约及周期 的K线用同一个serial存储 + "focus_datetime": int(self._current_dt), + "focus_position": 8964, + } + chart_a = _get_obj(self._data, ["charts", chart_id_a]) + chart_b = _get_obj(self._data, ["charts", chart_id_b]) + symbol_list = ins.split(',') + current_id = None # 当前数据指针 + if dur == 0: + serials = [_get_obj(self._data, ["ticks", symbol_list[0]])] + else: + serials = [_get_obj(self._data, ["klines", s, str(dur)]) for s in symbol_list] + async with TqChan(self._api, last_only=True) as update_chan: + for serial in serials: + serial["_listener"].add(update_chan) + chart_a["_listener"].add(update_chan) + chart_b["_listener"].add(update_chan) + await self._md_send_chan.send(chart_info.copy()) + try: + async for _ in update_chan: + chart = _get_obj(self._data, ["charts", chart_info["chart_id"]]) + if not (chart_info.items() <= _get_obj(chart, ["state"]).items()): + # 当前请求还没收齐回应, 不应继续处理 + continue + left_id = chart.get("left_id", -1) + right_id = chart.get("right_id", -1) + if (left_id == -1 and right_id == -1) or chart.get("more_data", True): + continue # 定位信息还没收到, 数据没有完全收到 + last_id = serials[0].get("last_id", -1) + if last_id == -1: + continue # 数据序列还没收到 + if self._data.get("mdhis_more_data", True): + self._data["_listener"].add(update_chan) + continue + else: + self._data["_listener"].discard(update_chan) + if current_id is None: + current_id = max(left_id, 0) + # 发送下一段 chart 8964 根 kline + chart_info["chart_id"] = chart_id_b if chart_info["chart_id"] == chart_id_a else chart_id_a + chart_info["left_kline_id"] = right_id + chart_info.pop("focus_datetime", None) + chart_info.pop("focus_position", None) + await self._md_send_chan.send(chart_info.copy()) + while True: + if current_id > last_id: + # 当前 id 已超过 last_id + return + # 将订阅的8964长度的窗口中的数据都遍历完后,退出循环,然后再次进入并处理下一窗口数据 + if current_id > right_id: + break + item = {k: v for k, v in serials[0]["data"].get(str(current_id), {}).items()} + if dur == 0: + diff = { + "ticks": { + ins: { + "last_id": current_id, + "data": { + str(current_id): item, + str(current_id - 8964): None, + } + } + } + } + if item["datetime"] > self._end_dt: # 超过结束时间 + return + yield item["datetime"], diff, self._get_quotes_from_tick(item) + else: + timestamp = item["datetime"] if dur < 86400000000000 else _get_trading_day_start_time( + item["datetime"]) + if timestamp > self._end_dt: # 超过结束时间 + return + binding = serials[0].get("binding", {}) + diff = { + "klines": { + symbol_list[0]: { + str(dur): { + "last_id": current_id, + "data": { + str(current_id): { + "datetime": item["datetime"], + "open": item["open"], + "high": item["open"], + "low": item["open"], + "close": item["open"], + "volume": 0, + "open_oi": item["open_oi"], + "close_oi": item["open_oi"], + } + } + } + } + } + } + for chart_id in self._serials[(ins, dur)]["chart_id_set"]: + diff["charts"] = { + chart_id: { + "right_id": current_id # api 中处理多合约 kline 需要 right_id 信息 + } + } + for i, symbol in enumerate(symbol_list): + if i == 0: + diff_binding = diff["klines"][symbol_list[0]][str(dur)].setdefault("binding", {}) + continue + other_id = binding.get(symbol, {}).get(str(current_id), -1) + if other_id >= 0: + diff_binding[symbol] = {str(current_id): str(other_id)} + other_item = serials[i]["data"].get(str(other_id), {}) + diff["klines"][symbol] = { + str(dur): { + "last_id": other_id, + "data": { + str(other_id): { + "datetime": other_item["datetime"], + "open": other_item["open"], + "high": other_item["open"], + "low": other_item["open"], + "close": other_item["open"], + "volume": 0, + "open_oi": other_item["open_oi"], + "close_oi": other_item["open_oi"], + } + } + } + } + yield timestamp, diff, self._get_quotes_from_kline_open( + self._data["quotes"][symbol_list[0]], + timestamp, + item) # K线刚生成时的数据都为开盘价 + timestamp = item["datetime"] + dur - 1000 \ + if dur < 86400000000000 else _get_trading_day_start_time(item["datetime"] + dur) - 1000 + if timestamp > self._end_dt: # 超过结束时间 + return + diff = { + "klines": { + symbol_list[0]: { + str(dur): { + "data": { + str(current_id): item, + } + } + } + } + } + for i, symbol in enumerate(symbol_list): + if i == 0: + continue + other_id = binding.get(symbol, {}).get(str(current_id), -1) + if other_id >= 0: + diff["klines"][symbol] = { + str(dur): { + "data": { + str(other_id): {k: v for k, v in + serials[i]["data"].get(str(other_id), {}).items()} + } + } + } + yield timestamp, diff, self._get_quotes_from_kline(self._data["quotes"][symbol_list[0]], + timestamp, + item) # K线结束时生成quote数据 + current_id += 1 + finally: + # 释放chart资源 + chart_info["ins_list"] = "" + await self._md_send_chan.send(chart_info.copy()) + chart_info["chart_id"] = chart_id_b if chart_info["chart_id"] == chart_id_a else chart_id_a + await self._md_send_chan.send(chart_info.copy()) + + def _gc_data(self): + # api 应该删除的数据 diff + need_rangeset = {} + for ins, dur in self._serials: + if dur == 0: # tick 在发送数据过程中已经回收内存 + continue + symbol_list = ins.split(',') + for s in symbol_list: + need_rangeset.setdefault((s, dur), []) + main_serial = _get_obj(self._data, ["klines", symbol_list[0], str(dur)]) + main_serial_rangeset = self._sended_to_api.get((symbol_list[0], dur), []) # 此 request 还没有给 api 发送过任何数据时为 [] + if not main_serial_rangeset: + continue + last_id = main_serial_rangeset[-1][-1] - 1 + assert last_id > -1 + need_rangeset[(symbol_list[0], dur)] = _rangeset_range_union(need_rangeset[(symbol_list[0], dur)], + (last_id - 8963, last_id + 1)) + for symbol in symbol_list[1:]: + symbol_need_rangeset = [] + symbol_binding = main_serial.get("binding", {}).get(symbol, {}) + if symbol_binding: + for i in range(last_id - 8963, last_id + 1): + other_id = symbol_binding.get(str(i)) + if other_id: + symbol_need_rangeset = _rangeset_range_union(symbol_need_rangeset, (other_id, other_id + 1)) + if symbol_need_rangeset: + need_rangeset[(symbol, dur)] = _rangeset_union(need_rangeset[(symbol, dur)], symbol_need_rangeset) + + gc_rangeset = {} + for key, rs in self._sended_to_api.items(): + gc_rangeset[key] = _rangeset_difference(rs, need_rangeset.get(key, [])) + + # 更新 self._sended_to_api + for key, rs in gc_rangeset.items(): + self._sended_to_api[key] = _rangeset_difference(self._sended_to_api[key], rs) + + gc_klines_diff = {} + for (symbol, dur), rs in gc_rangeset.items(): + gc_klines_diff.setdefault(symbol, {}) + gc_klines_diff[symbol][str(dur)] = {"data": {}} + serial = _get_obj(self._data, ["klines", symbol, str(dur)]) + serial_binding = serial.get("binding", None) + if serial_binding: + gc_klines_diff[symbol][str(dur)]["binding"] = {s: {} for s in serial_binding.keys()} + for start_id, end_id in rs: + for i in range(start_id, end_id): + gc_klines_diff[symbol][str(dur)]["data"][str(i)] = None + if serial_binding: + for s, s_binding in serial_binding.items(): + gc_klines_diff[symbol][str(dur)]["binding"][s][str(i)] = None + return {"klines": gc_klines_diff} + + @staticmethod + def _get_quotes_from_tick(tick): + quote = {k: v for k, v in tick.items()} + quote["datetime"] = datetime.fromtimestamp(tick["datetime"] / 1e9).strftime("%Y-%m-%d %H:%M:%S.%f") + return [quote] + + @staticmethod + def _get_quotes_from_kline_open(info, timestamp, kline): + return [ + { # K线刚生成时的数据都为开盘价 + "datetime": datetime.fromtimestamp(timestamp / 1e9).strftime("%Y-%m-%d %H:%M:%S.%f"), + "ask_price1": kline["open"] + info["price_tick"], + "ask_volume1": 1, + "bid_price1": kline["open"] - info["price_tick"], + "bid_volume1": 1, + "last_price": kline["open"], + "highest": float("nan"), + "lowest": float("nan"), + "average": float("nan"), + "volume": 0, + "amount": float("nan"), + "open_interest": kline["open_oi"], + }, + ] + + @staticmethod + def _get_quotes_from_kline(info, timestamp, kline): + """ + 分为三个包发给下游: + 1. 根据 diff 协议,对于用户收到的最终结果没有影响 + 2. TqSim 撮合交易会按顺序处理收到的包,分别比较 high、low、close 三个价格对应的买卖价 + 3. TqSim 撮合交易只用到了买卖价,所以最新价只产生一次 close,而不会发送三次 + """ + return [ + { + "datetime": datetime.fromtimestamp(timestamp / 1e9).strftime("%Y-%m-%d %H:%M:%S.%f"), + "ask_price1": kline["high"] + info["price_tick"], + "ask_volume1": 1, + "bid_price1": kline["high"] - info["price_tick"], + "bid_volume1": 1, + "last_price": kline["close"], + "highest": float("nan"), + "lowest": float("nan"), + "average": float("nan"), + "volume": 0, + "amount": float("nan"), + "open_interest": kline["close_oi"], + }, + { + "ask_price1": kline["low"] + info["price_tick"], + "bid_price1": kline["low"] - info["price_tick"], + }, + { + "ask_price1": kline["close"] + info["price_tick"], + "bid_price1": kline["close"] - info["price_tick"], + } + ] diff --git a/tqsdk/backtest/replay.py b/tqsdk/backtest/replay.py new file mode 100644 index 00000000..3b788e73 --- /dev/null +++ b/tqsdk/backtest/replay.py @@ -0,0 +1,157 @@ +#!usr/bin/env python3 +# -*- coding:utf-8 -*- + +__author__ = 'mayanqiong' + + +import asyncio +import json +import time +from datetime import date + +import aiohttp +import requests + +from tqsdk.channel import TqChan + + +class TqReplay(object): + """天勤复盘类""" + + def __init__(self, replay_dt: date): + """ + 除了传统的回测模式以外,TqSdk 提供独具特色的复盘模式,它与回测模式有以下区别 + + 1.复盘模式为时间驱动,回测模式为事件驱动 + + 复盘模式下,你可以指定任意一天交易日,后端行情服务器会传输用户订阅合约的当天的所有历史行情数据,重演当天行情,而在回测模式下,我们根据用户订阅的合约周期数据来进行推送 + + 因此在复盘模式下K线更新和实盘一模一样,而回测模式下就算订阅了 Tick 数据,回测中任意周期 K 线最后一根的 close 和其他数据也不会随着 Tick 更新而更新,而是随着K线频率生成和结束时更新一次 + + 2.复盘和回测的行情速度 + + 因为两者的驱动机制不同,回测会更快,但是我们在复盘模式下也提供行情速度调节功能,可以结合web_gui来实现 + + 3.复盘目前只支持单日复盘 + + 因为复盘提供对应合约当日全部历史行情数据,对后端服务器会有较大压力,目前只支持复盘模式下选择单日进行复盘 + + Args: + replay_dt (date): 指定复盘交易日 + """ + if isinstance(replay_dt, date): + self._replay_dt = replay_dt + else: + raise Exception("复盘时间(dt)类型 %s 错误, 请检查 dt 数据类型是否填写正确" % (type(replay_dt))) + if self._replay_dt.weekday() >= 5: + # 0~6, 检查周末[5,6] 提前抛错退出 + raise Exception("无法创建复盘服务器,请检查复盘日期后重试。") + self._default_speed = 1 + self._api = None + + def _create_server(self, api): + self._api = api + self._logger = api._logger.getChild("TqReplay") # 调试信息输出 + self._logger.debug('replay prepare', replay_dt=self._replay_dt) + + session = self._prepare_session() + self._session_url = "http://%s:%d/t/rmd/replay/session/%s" % ( + session["ip"], session["session_port"], session["session"]) + self._ins_url = "http://%s:%d/t/rmd/replay/session/%s/symbol" % ( + session["ip"], session["session_port"], session["session"]) + self._md_url = "ws://%s:%d/t/rmd/front/mobile" % (session["ip"], session["gateway_web_port"]) + + self._server_status = None + self._server_status = self._wait_server_status("running", 60) + if self._server_status == "running": + self._logger.debug('replay start successed', replay_dt=self._replay_dt) + return self._ins_url, self._md_url + else: + self._logger.debug('replay start failed', replay_dt=self._replay_dt) + raise Exception("无法创建复盘服务器,请检查复盘日期后重试。") + + async def _run(self): + try: + self._send_chan = TqChan(self._api) + self._send_chan.send_nowait({"aid": "ratio", "speed": self._default_speed}) + _senddata_task = self._api.create_task(self._senddata_handler()) + while True: + await self._send_chan.send({"aid": "heartbeat"}) + await asyncio.sleep(30) + finally: + await self._send_chan.close() + _senddata_task.cancel() + await asyncio.gather(_senddata_task, return_exceptions=True) + + def _prepare_session(self): + create_session_url = "http://replay.api.shinnytech.com/t/rmd/replay/create_session" + response = requests.post(create_session_url, + headers=self._api._base_headers, + data=json.dumps({'dt': self._replay_dt.strftime("%Y%m%d")}), + timeout=5) + if response.status_code == 200: + return json.loads(response.content) + else: + raise Exception("创建复盘服务器失败,请检查复盘日期后重试。") + + def _wait_server_status(self, target_status, timeout): + """等服务器状态为 target_status,超时时间 timeout 秒""" + deadline = time.time() + timeout + server_status = self._get_server_status() + while deadline > time.time(): + if target_status == server_status: + break + else: + time.sleep(1) + server_status = self._get_server_status() + return server_status + + def _get_server_status(self): + try: + response = requests.get(self._session_url, + headers=self._api._base_headers, + timeout=5) + if response.status_code == 200: + return json.loads(response.content)["status"] + else: + raise Exception("无法创建复盘服务器,请检查复盘日期后重试。") + except requests.exceptions.ConnectionError as e: + # 刚开始 _session_url 还不能访问的时候~ + return None + + async def _senddata_handler(self): + try: + session = aiohttp.ClientSession(headers=self._api._base_headers) + async for data in self._send_chan: + await session.post(self._session_url, data=json.dumps(data)) + finally: + await session.post(self._session_url, data=json.dumps({"aid": "terminate"})) + await session.close() + + def set_replay_speed(self, speed: float = 10.0) -> None: + """ + 调整复盘服务器行情推进速度 + + Args: + speed (float): 复盘服务器行情推进速度, 默认为 10.0 + + Example:: + + from datetime import date + from tqsdk import TqApi, TqAuth, TqReplay + replay = TqReplay(date(2020, 9, 10)) + api = TqApi(backtest=replay, auth=("信易账户,账户密码")) + replay.set_replay_speed(3.0) + quote = api.get_quote("SHFE.cu2012") + while True: + api.wait_update() + if api.is_changing(quote): + print("最新价", quote.datetime, quote.last_price) + + """ + if self._api: + self._send_chan.send_nowait({"aid": "ratio", "speed": speed}) + else: + # _api 未初始化,只记录用户设定的速度,在复盘服务器启动完成后,发动请求 + self._default_speed = speed + diff --git a/tqsdk/backtest/utils.py b/tqsdk/backtest/utils.py new file mode 100644 index 00000000..64487aa3 --- /dev/null +++ b/tqsdk/backtest/utils.py @@ -0,0 +1,102 @@ +#!usr/bin/env python3 +# -*- coding:utf-8 -*- + +__author__ = 'mayanqiong' + +from datetime import datetime + +import requests + +from tqsdk.calendar import TqContCalendar + + +class TqBacktestContinuous(object): + + def __init__(self, start_dt: int, end_dt: int, headers=None) -> None: + """ + 为回测时提供某个交易日的主连表 + start_dt 开始的交易日 + end_dt 结束的交易日 + """ + self._cont_calendar = TqContCalendar(start_dt=datetime.fromtimestamp(start_dt / 1e9), + end_dt=datetime.fromtimestamp(end_dt / 1e9), + headers=headers) + + def _get_history_cont_quotes(self, trading_day): + df = self._cont_calendar._get_cont_underlying_on_date(dt=datetime.fromtimestamp(trading_day / 1e9)) + quotes = {k: {"underlying_symbol": df.iloc[0][k]} for k in df.columns if k.startswith("KQ.m")} + return quotes + + +class TqBacktestDividend(object): + + def __init__(self, start_dt: int, end_dt: int, headers=None) -> None: + """ + 为回测时提供分红送股信息 + start_dt 开始的交易日 + end_dt 结束的交易日 + """ + self._headers = headers + self._start_dt = start_dt + self._end_dt = end_dt + self._start_date = datetime.fromtimestamp(self._start_dt / 1000000000).strftime('%Y%m%d') + self._end_date = datetime.fromtimestamp(self._end_dt / 1000000000).strftime('%Y%m%d') + self._stocks = {} # 记录全部股票合约及从 stock-dividend 服务获取的原始数据 + + def _get_dividend(self, quotes, trading_day): + dt = datetime.fromtimestamp(trading_day / 1000000000).strftime('%Y%m%d') + self._request_stock_dividend(quotes) + rsp_quotes = {} + # self._stocks 中应该已经记录了 quotes 中全部股票合约 + for symbol, stock in self._stocks.items(): + if stock['request_successed'] is True: # 从 stock-dividend 服务获取的原始数据 + rsp_quotes[symbol] = { + 'cash_dividend_ratio': [f"{item['drdate']},{item['cash']}" for item in stock['dividend_list'] + if item['recorddate'] <= dt and item['cash'] > 0], # 除权除息日,每股分红(税后) + 'stock_dividend_ratio': [f"{item['drdate']},{item['share']}" for item in stock['dividend_list'] + if item['recorddate'] <= dt and item['share'] > 0] # 除权除息日,每股送转股数量 + } + else: + # todo: stock['request_successed'] == False 表示请求不成功, 退回到原始合约服务中的分红送股数据, 用户会收到未来数据, + # 但是 tqsim 能保证取到结算时下一个交易日的分红信息 + # 此时,quotes 为 tqbacktest._data['quotes'] 应该保存了全部的合约信息 + rsp_quotes[symbol] = { + 'cash_dividend_ratio': quotes[symbol].get('cash_dividend_ratio', []), + 'stock_dividend_ratio': quotes[symbol].get('stock_dividend_ratio', []) + } + return rsp_quotes + + def _request_stock_dividend(self, quotes): + # 对于股票合约,从 stock-dividend 服务请求回测时间段的分红方案 + stock_list = [s for s in quotes if quotes[s]['ins_class'] == 'STOCK' and s not in self._stocks] + if len(stock_list) == 0: + return + # 每个合约只会请求一次,请求失败就退回到原始合约服务中的分红送股数据 + for s in stock_list: + self._stocks[s] = { + 'request_successed': False, + 'dividend_list': [] + } + # https://github.com/shinnytech/stock-dividend + rsp = requests.get(url="https://stock-dividend.shinnytech.com/query", + headers=self._headers, timeout=30, + params={ + "stock_list": ','.join(stock_list), + "start_date": self._start_date, + "end_date": self._end_date + }) + if rsp.status_code != 200: + return + result = rsp.json().get('result') + for s in stock_list: + self._stocks[s]['request_successed'] = True + for item in result: + """ + stockcode: 证券代码 + marketcode: 市场代码 + share: 每股送转股数量 + cash: 每股分红(税后) + recorddate: 股权登记日 + drdate: 除权除息日 + """ + self._stocks[f"{item['marketcode']}.{item['stockcode']}"]["dividend_list"].append(item) diff --git a/tqsdk/calendar.py b/tqsdk/calendar.py index fced1208..821b468f 100644 --- a/tqsdk/calendar.py +++ b/tqsdk/calendar.py @@ -6,22 +6,20 @@ __author__ = 'mayanqiong' import os -from typing import Union, List - -import requests from datetime import date, datetime +from typing import Union, List import pandas as pd - +import requests rest_days_df = None chinese_holidays_range = None -def _init_chinese_rest_days(): +def _init_chinese_rest_days(headers=None): global rest_days_df, chinese_holidays_range if rest_days_df is None: - rsp = requests.get("https://files.shinnytech.com/shinny_chinese_holiday.json", timeout=30) + rsp = requests.get("https://files.shinnytech.com/shinny_chinese_holiday.json", timeout=30, headers=headers) chinese_holidays = rsp.json() _first_day = date(int(chinese_holidays[0].split('-')[0]), 1, 1) # 首个日期所在年份的第一天 _last_day = date(int(chinese_holidays[-1].split('-')[0]), 12, 31) # 截止日期所在年份的最后一天 @@ -31,7 +29,7 @@ def _init_chinese_rest_days(): return chinese_holidays_range -def _get_trading_calendar(start_dt: date, end_dt: date): +def _get_trading_calendar(start_dt: date, end_dt: date, headers=None): """ 获取一段时间内,每天是否是交易日 @@ -43,7 +41,7 @@ def _get_trading_calendar(start_dt: date, end_dt: date): 2019-12-08 False 2019-12-09 True """ - _init_chinese_rest_days() + _init_chinese_rest_days(headers=headers) df = pd.DataFrame() df['date'] = pd.Series(pd.date_range(start=start_dt, end=end_dt, freq="D")) df['trading'] = df['date'].dt.dayofweek.lt(5) @@ -68,7 +66,7 @@ class TqContCalendar(object): continuous = None - def __init__(self, start_dt: date, end_dt: date, symbols: Union[List[str], None] = None) -> None: + def __init__(self, start_dt: date, end_dt: date, symbols: Union[List[str], None] = None, headers=None) -> None: """ 初始化主连日历表 :param date start_dt: 开始交易日日期 @@ -76,11 +74,11 @@ def __init__(self, start_dt: date, end_dt: date, symbols: Union[List[str], None] :param list[str] symbols: 主连合约列表 :return: """ - self.df = _get_trading_calendar(start_dt=start_dt, end_dt=end_dt) + self.df = _get_trading_calendar(start_dt=start_dt, end_dt=end_dt, headers=headers) self.df = self.df.loc[self.df.trading, ['date']] # 只保留交易日 self.df.reset_index(inplace=True, drop=True) if TqContCalendar.continuous is None: - rsp = requests.get(os.getenv("TQ_CONT_TABLE_URL", "https://files.shinnytech.com/continuous_table.json")) # 下载历史主连合约信息 + rsp = requests.get(os.getenv("TQ_CONT_TABLE_URL", "https://files.shinnytech.com/continuous_table.json"), headers=headers) # 下载历史主连合约信息 rsp.raise_for_status() TqContCalendar.continuous = {f"KQ.m@{k}": v for k, v in rsp.json().items()} if symbols is not None: diff --git a/tqsdk/data_series.py b/tqsdk/data_series.py index b0fb6bf9..5cd82fad 100644 --- a/tqsdk/data_series.py +++ b/tqsdk/data_series.py @@ -3,8 +3,8 @@ __author__ = 'mayanqiong' import os +import shutil import struct -from datetime import datetime import numpy as np import pandas as pd @@ -179,7 +179,7 @@ async def _download_data_series(self, rangeset): temp_file.close() if start_id is not None and end_id is not None: target_filename = os.path.join(CACHE_DIR, f"{symbol}.{self._dur_nano}.{start_id}.{end_id + 1}") - os.rename(temp_filename, target_filename) + shutil.move(temp_filename, target_filename) finally: task.cancel() await task diff --git a/tqsdk/multiaccount.py b/tqsdk/multiaccount.py index 1d62b615..8608213e 100644 --- a/tqsdk/multiaccount.py +++ b/tqsdk/multiaccount.py @@ -9,8 +9,8 @@ from tqsdk.connect import TqConnect, TdReconnectHandler from tqsdk.channel import TqChan -from tqsdk.tradeable import TqAccount, TqKq, TqKqStock, TqSim -from tqsdk.tradeable.interface import IStock +from tqsdk.tradeable import TqAccount, TqKq, TqKqStock, TqSim, TqSimStock, BaseSim, BaseOtg +from tqsdk.tradeable.mixin import StockMixin class TqMultiAccount(object): @@ -28,12 +28,12 @@ class TqMultiAccount(object): """ - def __init__(self, accounts: Optional[List[Union[TqAccount, TqKq, TqKqStock, TqSim]]] = None): + def __init__(self, accounts: Optional[List[Union[TqAccount, TqKq, TqKqStock, TqSim, TqSimStock]]] = None): """ 创建 TqMultiAccount 实例 Args: - accounts (List[Union[TqAccount, TqKq, TqKqStock, TqSim]]): [可选] 多账户列表, 若未指定任何账户, 则为 [TqSim()] + accounts (List[Union[TqAccount, TqKq, TqKqStock, TqSim, TqSimStock]]): [可选] 多账户列表, 若未指定任何账户, 则为 [TqSim()] Example1:: @@ -76,7 +76,7 @@ def __init__(self, accounts: Optional[List[Union[TqAccount, TqKq, TqKqStock, TqS """ self._account_list = accounts if accounts else [TqSim()] - self._has_tq_account = any([True for a in self._account_list if isinstance(a, TqAccount)]) # 是否存在实盘账户(TqAccount/TqKq/TqKqStock) + self._has_tq_account = any([True for a in self._account_list if isinstance(a, BaseOtg)]) # 是否存在实盘账户(TqAccount/TqKq/TqKqStock) self._map_conn_id = {} # 每次建立连接时,记录每个 conn_id 对应的账户 if self._has_duplicate_account(): raise Exception("多账户列表中不允许使用重复的账户实例.") @@ -86,7 +86,7 @@ def _has_duplicate_account(self): account_set = set([a._account_key for a in self._account_list]) return len(account_set) != len(self._account_list) - def _check_valid(self, account: Union[str, TqAccount, TqKq, TqKqStock, TqSim, None]): + def _check_valid(self, account: Union[str, TqAccount, TqKq, TqKqStock, TqSim, TqSimStock, None]): """ 查询委托、成交、资产、委托时, 需要指定账户实例 account: 类型 str 表示 account_key,其他为账户类型或者 None @@ -112,7 +112,7 @@ def _get_account_key(self, account): def _is_stock_type(self, account_or_account_key): """ 判断账户类型是否为股票账户 """ acc = self._check_valid(account_or_account_key) - return isinstance(acc, IStock) + return isinstance(acc, StockMixin) def _get_trade_more_data_and_order_id(self, data): """ 获取业务信息截面 trade_more_data 标识,当且仅当所有账户的标识置为 false 时,业务信息截面就绪 """ @@ -133,7 +133,7 @@ def _run(self, api, api_send_chan, api_recv_chan, ws_md_send_chan, ws_md_recv_ch _recv_chan._logger_bind(chan_name=f"recv from account_{index}") ws_md_send_chan._logger_bind(chan_from=f"account_{index}") ws_md_recv_chan._logger_bind(chan_to=f"account_{index}") - if isinstance(account, TqSim): + if isinstance(account, BaseSim): # 启动模拟账户实例 self._api.create_task( account._run(self._api, _send_chan, _recv_chan, ws_md_send_chan, ws_md_recv_chan)) diff --git a/tqsdk/report.py b/tqsdk/report.py index 5d71480d..8d2f250e 100644 --- a/tqsdk/report.py +++ b/tqsdk/report.py @@ -7,7 +7,7 @@ import numpy as np from pandas import DataFrame, Series -from tqsdk.objs import Account, Trade +from tqsdk.objs import Account, Trade, SecurityAccount, SecurityTrade from tqsdk.tafunc import get_sharp, get_sortino, get_calmar, _cum_counts TRADING_DAYS_OF_YEAR = 250 @@ -22,7 +22,7 @@ class TqReport(object): """ - def __init__(self, report_id: str, trade_log: Optional[Dict] = None, quotes: Optional[Dict] = None): + def __init__(self, report_id: str, trade_log: Optional[Dict] = None, quotes: Optional[Dict] = None, account_type: str = "FUTURE"): """ 本模块为给 TqSim 提供交易成交统计 Args: @@ -47,21 +47,25 @@ def __init__(self, report_id: str, trade_log: Optional[Dict] = None, quotes: Opt self.report_id = report_id self.trade_log = trade_log self.quotes = quotes + self.account_type = account_type self.date_keys = sorted(trade_log.keys()) self.account_df, self.trade_df = self._get_df() # default metrics - self.default_metrics = self._get_default_metrics() + self.default_metrics = self._get_default_metrics() if self.account_type == "FUTURE" else self._get_stock_metrics() def _get_df(self): + type_account = Account if self.account_type == "FUTURE" else SecurityAccount + type_trade = Trade if self.account_type == "FUTURE" else SecurityTrade account_data = [{'date': dt} for dt in self.date_keys] for item in account_data: item.update(self.trade_log[item['date']]['account']) - account_df = DataFrame(data=account_data, columns=['date'] + list(Account(None).keys())) + account_df = DataFrame(data=account_data, columns=['date'] + list(type_account(None).keys())) trade_array = [] for date in self.date_keys: trade_array.extend(self.trade_log[date]['trades']) - trade_df = DataFrame(data=trade_array, columns=list(Trade(None).keys())) - trade_df["offset1"] = trade_df["offset"].replace("CLOSETODAY", "CLOSE") + trade_df = DataFrame(data=trade_array, columns=list(type_trade(None).keys())) + if type_trade == Trade: + trade_df["offset1"] = trade_df["offset"].replace("CLOSETODAY", "CLOSE") return account_df, trade_df def _get_default_metrics(self): @@ -82,6 +86,52 @@ def _get_default_metrics(self): "tqsdk_punchline": "" } + def _get_stock_metrics(self): + if self.account_df.shape[0] > 0: + init_asset = self.account_df.iloc[0]['asset_his'] + asset = self.account_df.iloc[-1]['asset'] + self.account_df['profit'] = self.account_df['asset'] - self.account_df['asset'].shift(fill_value=init_asset) # 每日收益 + self.account_df['is_profit'] = np.where(self.account_df['profit'] > 0, 1, 0) # 是否收益 + self.account_df['is_loss'] = np.where(self.account_df['profit'] < 0, 1, 0) # 是否亏损 + self.account_df['daily_yield'] = self.account_df['asset'] / self.account_df['asset'].shift(fill_value=init_asset) - 1 # 每日收益率 + self.account_df['max_asset'] = self.account_df['asset'].cummax() # 当前单日最大权益 + self.account_df['drawdown'] = (self.account_df['max_asset'] - self.account_df['asset']) / self.account_df['max_asset'] # 回撤 + _ror = asset / init_asset + return { + "start_date": self.account_df.iloc[0]["date"], + "end_date": self.account_df.iloc[-1]["date"], + "init_asset": init_asset, + "asset": init_asset, + "start_asset": init_asset, + "end_asset": asset, + "ror": _ror - 1, # 收益率 + "annual_yield": _ror ** (TRADING_DAYS_OF_YEAR / self.account_df.shape[0]) - 1, # 年化收益率 + "trading_days": self.account_df.shape[0], # 总交易天数 + "cum_profit_days": self.account_df['is_profit'].sum(), # 累计盈利天数 + "cum_loss_days": self.account_df['is_loss'].sum(), # 累计亏损天数 + "max_drawdown": self.account_df['drawdown'].max(), # 最大回撤 + "fee": self.account_df['buy_fee_today'].sum() + self.account_df['sell_fee_today'].sum(), # 总手续费 + "buy_times": self.trade_df.loc[self.trade_df["direction"] == "BUY"].shape[0], # 买次数 + "sell_times": self.trade_df.loc[self.trade_df["direction"] == "SELL"].shape[0], # 卖次数 + "max_cont_profit_days": _cum_counts(self.account_df['is_profit']).max(), # 最大连续盈利天数 + "max_cont_loss_days": _cum_counts(self.account_df['is_loss']).max(), # 最大连续亏损天数 + "sharpe_ratio": get_sharp(self.account_df['daily_yield']), # 年化夏普率 + "calmar_ratio": get_calmar(self.account_df['daily_yield'], self.account_df['drawdown'].max()), # 年化卡玛比率 + "sortino_ratio": get_sortino(self.account_df['daily_yield']), # 年化索提诺比率 + "tqsdk_punchline": self._get_tqsdk_punchlines(_ror - 1) + } + else: + return { + "profit_loss_ratio": float('nan'), # 盈亏额比例 + "ror": float('nan'), # 收益率 + "annual_yield": float('nan'), # 年化收益率 + "max_drawdown": float('nan'), # 最大回撤 + "sharpe_ratio": float('nan'), # 年化夏普率 + "sortino_ratio": float('nan'), # 年化索提诺比率 + "fee": 0, # 总手续费 + "tqsdk_punchline": "" + } + def _get_account_stat_metrics(self): init_balance = self.account_df.iloc[0]['pre_balance'] balance = self.account_df.iloc[-1]['balance'] diff --git a/tqsdk/tqwebhelper.py b/tqsdk/tqwebhelper.py index d91e1380..54a61d26 100644 --- a/tqsdk/tqwebhelper.py +++ b/tqsdk/tqwebhelper.py @@ -12,6 +12,7 @@ import numpy as np import simplejson from aiohttp import web +from tqsdk.tradeable.sim.basesim import BaseSim from tqsdk.auth import TqAuth from tqsdk.backtest import TqBacktest, TqReplay @@ -88,12 +89,12 @@ async def _run(self, api_send_chan, api_recv_chan, web_send_chan, web_recv_chan) file_path = os.path.abspath(sys.argv[0]) file_name = os.path.basename(file_path) # 初始化数据截面 - accounts_info = {} + accounts_info = { + acc._account_key: {"td_url_status": True if isinstance(acc, BaseSim) else '-'} + for acc in self._api._account._account_list + } for acc in self._api._account._account_list: - accounts_info[acc._account_key] = { - "td_url_status": True if isinstance(acc, TqSim) else '-' - } - accounts_info[acc._account_key].update(acc._get_baseinfo()) + accounts_info[acc._account_key].update(acc._account_info) self._data = { "action": { "mode": "replay" if isinstance(self._api._backtest, TqReplay) else "backtest" if isinstance(self._api._backtest, TqBacktest) else "run", diff --git a/tqsdk/tradeable/__init__.py b/tqsdk/tradeable/__init__.py index 57ff0d5c..9e51d4de 100644 --- a/tqsdk/tradeable/__init__.py +++ b/tqsdk/tradeable/__init__.py @@ -6,4 +6,5 @@ from tqsdk.tradeable.otg.base_otg import BaseOtg from tqsdk.tradeable.otg import TqAccount, TqKq, TqKqStock -from tqsdk.tradeable.sim import TqSim +from tqsdk.tradeable.sim.basesim import BaseSim +from tqsdk.tradeable.sim import TqSim, TqSimStock diff --git a/tqsdk/tradeable/mixin.py b/tqsdk/tradeable/mixin.py new file mode 100644 index 00000000..8b891b57 --- /dev/null +++ b/tqsdk/tradeable/mixin.py @@ -0,0 +1,351 @@ +#!usr/bin/env python3 +# -*- coding:utf-8 -*- +__author__ = 'yanqiong' + + +from typing import Optional, Union + +from tqsdk.diff import _get_obj +from tqsdk.entity import Entity +from tqsdk.objs import Account, Order, Trade, Position, SecurityAccount, SecurityOrder, SecurityTrade, SecurityPosition + + +def _get_api_instance(self): + if hasattr(self, '_api'): + return self._api + import inspect + raise Exception(f"未初始化 TqApi。请在 api 初始化后调用 {inspect.stack()[1].function}。") + + +class FutureMixin: + + _account_type = "FUTURE" + + def get_account(self) -> Account: + """ + 获取用户账户资金信息 + + Returns: + :py:class:`~tqsdk.objs.Account`: 返回一个账户对象引用. 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新 + + Example1:: + + # 获取当前浮动盈亏 + from tqsdk import TqApi, TqAuth + + tqacc = TqAccount("N南华期货", "123456", "123456") + api = TqApi(account=tqacc, auth=TqAuth("信易账户", "账户密码")) + account = tqacc.get_account() + print(account.float_profit) + + # 预计的输出是这样的: + 2180.0 + ... + + Example2:: + + # 多账户模式下, 分别获取各账户浮动盈亏 + from tqsdk import TqApi, TqAuth, TqMultiAccount, TqAccount, TqKq, TqSim + + account = TqAccount("N南华期货", "123456", "123456") + tqkq = TqKq() + tqsim = TqSim() + api = TqApi(TqMultiAccount([account, tqkq, tqsim]), auth=TqAuth("信易账户", "账户密码")) + account1 = account.get_account() + account2 = tqkq.get_account() + account3 = tqsim.get_account() + print(f"账户 1 浮动盈亏 {account1.float_profit}, 账户 2 浮动盈亏 {account2.float_profit}, 账户 3 浮动盈亏 {account3.float_profit}") + api.close() + + """ + api = _get_api_instance(self) + return _get_obj(api._data, ["trade", self._account_key, "accounts", "CNY"], Account(api)) + + def get_position(self, symbol: Optional[str] = None) -> Union[Position, Entity]: + """ + 获取用户持仓信息 + + Args: + symbol (str): [可选]合约代码, 不填则返回所有持仓 + + Returns: + :py:class:`~tqsdk.objs.Position`: 当指定了 symbol 时, 返回一个持仓对象引用。 + 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新。 + + 不填 symbol 参数调用本函数, 将返回包含用户所有持仓的一个 ``tqsdk.objs.Entity`` 对象引用, 使用方法与dict一致, \ + 其中每个元素的 key 为合约代码, value 为 :py:class:`~tqsdk.objs.Position`。 + + 注意: 为保留一些可供用户查询的历史信息, 如 volume_long_yd(本交易日开盘前的多头持仓手数) 等字段, 因此服务器会返回当天已平仓合约( pos_long 和 pos_short 等字段为0)的持仓信息 + + Example1:: + + # 获取 DCE.m2109 当前浮动盈亏 + from tqsdk import TqApi, TqAuth, TqAccount + + tqacc = TqAccount("N南华期货", "123456", "123456") + api = TqApi(account=tqacc, auth=TqAuth("信易账户", "账户密码")) + position = tqacc.get_position("DCE.m2109") + print(position.float_profit_long + position.float_profit_short) + while api.wait_update(): + print(position.float_profit_long + position.float_profit_short) + + # 预计的输出是这样的: + 300.0 + 330.0 + ... + + Example2:: + + # 多账户模式下, 分别获取各账户浮动盈亏 + from tqsdk import TqApi, TqAuth, TqMultiAccount, TqAccount, TqKq, TqSim + + account = TqAccount("N南华期货", "123456", "123456") + tqkq = TqKq() + tqsim = TqSim() + api = TqApi(TqMultiAccount([account, tqkq, tqsim]), auth=TqAuth("信易账户", "账户密码")) + position1 = account.get_position("DCE.m2101") + position2 = tqkq.get_position("DCE.m2101") + position3 = tqsim.get_position("DCE.m2101") + print(f"账户 1 'DCE.m2101' 浮动盈亏 {position1.float_profit_long + position1.float_profit_short}, ", + f"账户 2 'DCE.m2101' 浮动盈亏 {position2.float_profit_long + position2.float_profit_short}, ", + f"账户 3 'DCE.m2101' 浮动盈亏 {position3.float_profit_long + position3.float_profit_short}") + api.close() + + """ + api = _get_api_instance(self) + if symbol: + return _get_obj(api._data, ["trade", self._account_key, "positions", symbol], Position(api)) + return _get_obj(api._data, ["trade", self._account_key, "positions"]) + + def get_order(self, order_id: Optional[str] = None) -> Union[Order, Entity]: + """ + 获取用户委托单信息 + + Args: + order_id (str): [可选]单号, 不填单号则返回所有委托单 + + Returns: + :py:class:`~tqsdk.objs.Order`: 当指定了 order_id 时, 返回一个委托单对象引用。 \ + 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新。 + + 不填 order_id 参数调用本函数, 将返回包含用户所有委托单的一个 ``tqsdk.objs.Entity`` 对象引用, \ + 使用方法与dict一致, 其中每个元素的key为委托单号, value为 :py:class:`~tqsdk.objs.Order` + + 注意: 在刚下单后, tqsdk 还没有收到回单信息时, 此对象中各项内容为空 + + Example1:: + + # 获取当前总挂单手数 + from tqsdk import TqApi, TqAuth + + tqacc = TqAccount("N南华期货", "123456", "123456") + api = TqApi(account=tqacc, auth=TqAuth("信易账户", "账户密码")) + orders = tqacc.get_order() + while True: + api.wait_update() + print(sum(order.volume_left for oid, order in orders.items() if order.status == "ALIVE")) + + # 预计的输出是这样的: + 3 + 3 + 0 + ... + + Example2:: + + # 多账户模式下, 分别获取各账户挂单手数 + from tqsdk import TqApi, TqAuth, TqMultiAccount, TqAccount, TqKq, TqSim + + account = TqAccount("N南华期货", "123456", "123456") + tqkq = TqKq() + tqsim = TqSim() + api = TqApi(TqMultiAccount([account, tqkq, tqsim]), auth=TqAuth("信易账户", "账户密码")) + orders1 = account.get_order() + orders2 = tqkq.get_order() + orders3 = tqsim.get_order() + print(f"账户 1 挂单手数 {sum(order.volume_left for order in orders1.values() if order.status == "ALIVE")}, ", + f"账户 2 挂单手数 {sum(order.volume_left for order in orders2.values() if order.status == "ALIVE")}, ", + f"账户 3 挂单手数 {sum(order.volume_left for order in orders3.values() if order.status == "ALIVE")}") + + order = account.get_order(order_id="订单号") + print(order) + api.close() + + """ + api = _get_api_instance(self) + if order_id: + return _get_obj(api._data, ["trade", self._account_key, "orders", order_id], Order(api)) + return _get_obj(api._data, ["trade", self._account_key, "orders"]) + + def get_trade(self, trade_id: Optional[str] = None) -> Union[Trade, Entity]: + """ + 获取用户成交信息 + + Args: + trade_id (str): [可选]成交号, 不填成交号则返回所有委托单 + + Returns: + :py:class:`~tqsdk.objs.Trade`: 当指定了trade_id时, 返回一个成交对象引用. \ + 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新. + + 不填trade_id参数调用本函数, 将返回包含用户当前交易日所有成交记录的一个tqsdk.objs.Entity对象引用, 使用方法与dict一致, \ + 其中每个元素的key为成交号, value为 :py:class:`~tqsdk.objs.Trade` + + 推荐优先使用 :py:meth:`~tqsdk.objs.Order.trade_records` 获取某个委托单的相应成交记录, 仅当确有需要时才使用本函数. + + Example:: + + # 多账户模式下, 分别获取各账户的成交记录 + from tqsdk import TqApi, TqAuth, TqMultiAccount + + account = TqAccount("N南华期货", "123456", "123456") + tqkq = TqKq() + tqsim = TqSim() + api = TqApi(TqMultiAccount([account, tqkq, tqsim]), auth=TqAuth("信易账户", "账户密码")) + trades1 = account.get_trade() + trades2 = tqkq.get_trade() + trades3 = tqsim.get_trade() + print(trades1) + print(trades2) + print(trades3) + api.close() + """ + api = _get_api_instance(self) + if trade_id: + return _get_obj(api._data, ["trade", self._account_key, "trades", trade_id], Trade(api)) + return _get_obj(api._data, ["trade", self._account_key, "trades"]) + + +class StockMixin: + + _account_type = "STOCK" + + def get_account(self) -> SecurityAccount: + """ + 获取用户账户资金信息 + + Returns: + :py:class:`~tqsdk.objs.SecurityAccount`: 返回一个账户对象引用. 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新 + + Example1:: + + # 获取当前浮动盈亏 + from tqsdk import TqApi, TqAuth + + tqacc = TqAccount("N南华期货", "123456", "123456") + api = TqApi(account=tqacc, auth=TqAuth("信易账户", "账户密码")) + account = tqacc.get_account() + print(account.float_profit) + + # 预计的输出是这样的: + 2180.0 + ... + + Example2:: + + # 多账户模式下, 分别获取各账户浮动盈亏 + from tqsdk import TqApi, TqAuth, TqMultiAccount, TqAccount, TqKq, TqSim + + account = TqAccount("N南华期货", "123456", "123456") + tqkq = TqKq() + tqsim = TqSim() + api = TqApi(TqMultiAccount([account, tqkq, tqsim]), auth=TqAuth("信易账户", "账户密码")) + account1 = account.get_account() + account2 = tqkq.get_account() + account3 = tqsim.get_account() + print(f"账户 1 浮动盈亏 {account1.float_profit}, 账户 2 浮动盈亏 {account2.float_profit}, 账户 3 浮动盈亏 {account3.float_profit}") + api.close() + + """ + api = _get_api_instance(self) + return _get_obj(api._data, ["trade", self._account_key, "accounts", "CNY"], SecurityAccount(api)) + + def get_position(self, symbol: Optional[str] = None) -> Union[SecurityPosition, Entity]: + """ + 获取用户持仓信息 + + Args: + symbol (str): [可选]合约代码, 不填则返回所有持仓 + + Returns: + :py:class:`~tqsdk.objs.SecurityPosition`: 当指定了 symbol 时, 返回一个持仓对象引用。 + 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新。 + + 不填 symbol 参数调用本函数, 将返回包含用户所有持仓的一个 ``tqsdk.objs.Entity`` 对象引用, 使用方法与dict一致, \ + 其中每个元素的 key 为合约代码, value 为 :py:class:`~tqsdk.objs.SecurityPosition`。 + + + Example:: + + from tqsdk import TqApi, TqAuth, TqKqStock + tqkqstock = TqKqStock() + api = TqApi(account=tqkqstock, auth=TqAuth("信易账户", "账户密码")) + position = tqkqstock.get_position('SSE.10003624') + print(f"建仓日期 {position.create_date}, 持仓数量 {position.volume}") + api.close() + + """ + api = _get_api_instance(self) + if symbol: + return _get_obj(api._data, ["trade", self._account_key, "positions", symbol], SecurityPosition(api)) + return _get_obj(api._data, ["trade", self._account_key, "positions"]) + + def get_order(self, order_id: Optional[str] = None) -> Union[SecurityOrder, Entity]: + """ + 获取用户委托单信息 + + Args: + order_id (str): [可选]单号, 不填单号则返回所有委托单 + + Returns: + :py:class:`~tqsdk.objs.SecurityOrder`: 当指定了 order_id 时, 返回一个委托单对象引用。 \ + 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新。 + + 不填 order_id 参数调用本函数, 将返回包含用户所有委托单的一个 ``tqsdk.objs.Entity`` 对象引用, \ + 使用方法与 dict 一致, 其中每个元素的 key 为委托单号, value为 :py:class:`~tqsdk.objs.SecurityOrder` + + 注意: 在刚下单后, tqsdk 还没有收到回单信息时, 此对象中各项内容为空 + + Example:: + + from tqsdk import TqApi, TqAuth, TqKqStock + tqkqstock = TqKqStock() + api = TqApi(account=tqkqstock, auth=TqAuth("信易账户", "账户密码")) + order = tqkqstock.get_order('委托单Id') + print(f"委托股数 {order.volume_orign}, 剩余股数 {order.volume_left}") + api.close() + """ + api = _get_api_instance(self) + if order_id: + return _get_obj(api._data, ["trade", self._account_key, "orders", order_id], SecurityOrder(api)) + return _get_obj(api._data, ["trade", self._account_key, "orders"]) + + def get_trade(self, trade_id: Optional[str] = None) -> Union[SecurityTrade, Entity]: + """ + 获取用户成交信息 + + Args: + trade_id (str): [可选]成交号, 不填成交号则返回所有委托单 + + Returns: + :py:class:`~tqsdk.objs.SecurityTrade`: 当指定了trade_id时, 返回一个成交对象引用. \ + 其内容将在 :py:meth:`~tqsdk.api.TqApi.wait_update` 时更新. + + 不填trade_id参数调用本函数, 将返回包含用户当前交易日所有成交记录的一个 ``tqsdk.objs.Entity`` 对象引用, 使用方法与dict一致, \ + 其中每个元素的key为成交号, value为 :py:class:`~tqsdk.objs.SecurityTrade` + + 推荐优先使用 :py:meth:`~tqsdk.objs.SecurityOrder.trade_records` 获取某个委托单的相应成交记录, 仅当确有需要时才使用本函数. + + Example:: + + from tqsdk import TqApi, TqAuth, TqKqStock + tqkqstock = TqKqStock() + api = TqApi(account=tqkqstock, auth=TqAuth("信易账户", "账户密码")) + trades = tqkqstock.get_trade('委托单Id') + [print(trade.trade_id, f"成交股数 {trade.volume}, 成交价格 {trade.price}") for trade in trades] + api.close() + """ + api = _get_api_instance(self) + if trade_id: + return _get_obj(api._data, ["trade", self._account_key, "trades", trade_id], SecurityTrade(api)) + return _get_obj(api._data, ["trade", self._account_key, "trades"]) diff --git a/tqsdk/tradeable/otg/base_otg.py b/tqsdk/tradeable/otg/base_otg.py index 43b18dc0..3a0c127c 100644 --- a/tqsdk/tradeable/otg/base_otg.py +++ b/tqsdk/tradeable/otg/base_otg.py @@ -5,18 +5,41 @@ import hashlib from typing import Optional -from tqsdk.tradeable.interface import IFuture, IStock +from tqsdk.tradeable.mixin import FutureMixin, StockMixin from tqsdk.tradeable.tradeable import Tradeable class BaseOtg(Tradeable): def __init__(self, broker_id: str, account_id: str, password: str, td_url: Optional[str] = None) -> None: + if not isinstance(broker_id, str): + raise Exception("broker_id 参数类型应该是 str") + if not isinstance(account_id, str): + raise Exception("account_id 参数类型应该是 str") if not isinstance(password, str): raise Exception("password 参数类型应该是 str") + self._broker_id = broker_id.strip() # 期货公司(用户登录 rsp_login 填的) + self._account_id = account_id.strip() # 期货账户 (用户登录 rsp_login 填的) self._password = password self._td_url = td_url - super(BaseOtg, self).__init__(broker_id=broker_id, account_id=account_id) + super(BaseOtg, self).__init__() + + def _get_account_key(self): + s = self._broker_id + self._account_id + return hashlib.md5(s.encode('utf-8')).hexdigest() + + @property + def _account_name(self): + return self._account_id + + @property + def _account_info(self): + info = super(BaseOtg, self)._account_info + info.update({ + "broker_id": self._broker_id, + "account_id": self._account_id + }) + return info async def _send_login_pack(self): """发送登录请求""" @@ -37,9 +60,9 @@ def _update_otg_info(self, api): else: self._td_url, account_type = api._auth._get_td_url(self._broker_id, self._account_id) if account_type == "FUTURE": - assert isinstance(self, IFuture) + assert isinstance(self, FutureMixin) else: - assert isinstance(self, IStock) + assert isinstance(self, StockMixin) async def _run(self, api, api_send_chan, api_recv_chan, md_send_chan, md_recv_chan, td_send_chan, td_recv_chan): self._api = api @@ -79,7 +102,3 @@ def _td_handler(self, pack): if "trade" in item: item["trade"][self._account_key] = item["trade"].pop(self._account_id) self._diffs.extend(pack_data) - - def _get_account_key(self): - s = self._broker_id + self._account_id - return hashlib.md5(s.encode('utf-8')).hexdigest() diff --git a/tqsdk/tradeable/otg/tqaccount.py b/tqsdk/tradeable/otg/tqaccount.py index f36810fc..d07f5c01 100644 --- a/tqsdk/tradeable/otg/tqaccount.py +++ b/tqsdk/tradeable/otg/tqaccount.py @@ -13,10 +13,10 @@ from typing import Optional from tqsdk.tradeable.otg.base_otg import BaseOtg -from tqsdk.tradeable.interface import IFuture +from tqsdk.tradeable.mixin import FutureMixin -class TqAccount(BaseOtg, IFuture): +class TqAccount(BaseOtg, FutureMixin): """天勤实盘账户类""" def __init__(self, broker_id: str, account_id: str, password: str, front_broker: Optional[str] = None, @@ -48,6 +48,21 @@ def __init__(self, broker_id: str, account_id: str, password: str, front_broker: super(TqAccount, self).__init__(broker_id, account_id, password, td_url) + def _get_account_key(self): + s = self._broker_id + self._account_id + s += self._front_broker if self._front_broker else "" + s += self._front_url if self._front_url else "" + s += self._td_url if self._td_url else "" + return hashlib.md5(s.encode('utf-8')).hexdigest() + + @property + def _account_info(self): + info = super(TqAccount, self)._account_info + info.update({ + "account_type": self._account_type + }) + return info + def _get_system_info(self): try: l = ctypes.c_int(344) @@ -96,10 +111,3 @@ async def _send_login_pack(self): await self._td_send_chan.send({ "aid": "confirm_settlement" }) # 自动发送确认结算单 - - def _get_account_key(self): - s = self._broker_id + self._account_id - s += self._front_broker if self._front_broker else "" - s += self._front_url if self._front_url else "" - s += self._td_url if self._td_url else "" - return hashlib.md5(s.encode('utf-8')).hexdigest() \ No newline at end of file diff --git a/tqsdk/tradeable/otg/tqkq.py b/tqsdk/tradeable/otg/tqkq.py index 9e3927b7..a9b44a47 100644 --- a/tqsdk/tradeable/otg/tqkq.py +++ b/tqsdk/tradeable/otg/tqkq.py @@ -6,10 +6,10 @@ from typing import Optional from tqsdk.tradeable.otg.base_otg import BaseOtg -from tqsdk.tradeable.interface import IFuture, IStock +from tqsdk.tradeable.mixin import FutureMixin, StockMixin -class TqKq(BaseOtg, IFuture): +class TqKq(BaseOtg, FutureMixin): """天勤快期模拟账户类""" def __init__(self, td_url: Optional[str] = None): @@ -23,13 +23,21 @@ def _account_name(self): # 用于界面展示的用户信息 return self._api._auth._user_name + @property + def _account_info(self): + info = super(TqKq, self)._account_info + info.update({ + "account_type": self._account_type + }) + return info + def _update_otg_info(self, api): self._account_id = api._auth._auth_id self._password = api._auth._auth_id super(TqKq, self)._update_otg_info(api) -class TqKqStock(BaseOtg, IStock): +class TqKqStock(BaseOtg, StockMixin): """天勤实盘类""" def __init__(self, td_url: Optional[str] = None): @@ -68,6 +76,14 @@ def _account_name(self): # 用于界面展示的用户信息 return self._api._auth._user_name + "_stock" + @property + def _account_info(self): + info = super(TqKqStock, self)._account_info + info.update({ + "account_type": self._account_type + }) + return info + def _update_otg_info(self, api): self._account_id = api._auth._auth_id + "-sim-securities" self._password = api._auth._auth_id diff --git a/tqsdk/tradeable/sim/__init__.py b/tqsdk/tradeable/sim/__init__.py index 5063bbde..ab3c8770 100644 --- a/tqsdk/tradeable/sim/__init__.py +++ b/tqsdk/tradeable/sim/__init__.py @@ -4,3 +4,4 @@ __author__ = 'mayanqiong' from tqsdk.tradeable.sim.tqsim import TqSim +from tqsdk.tradeable.sim.tqsim_stock import TqSimStock diff --git a/tqsdk/tradeable/sim/basesim.py b/tqsdk/tradeable/sim/basesim.py index 358cfb89..c666960a 100644 --- a/tqsdk/tradeable/sim/basesim.py +++ b/tqsdk/tradeable/sim/basesim.py @@ -7,24 +7,25 @@ import time from abc import abstractmethod from datetime import datetime -from typing import Type +from typing import Type, Union from tqsdk.channel import TqChan from tqsdk.datetime import _get_trading_day_from_timestamp, _get_trading_day_end_time, _get_trade_timestamp, \ - _is_in_trading_time + _is_in_trading_time, _format_from_timestamp_nano from tqsdk.diff import _get_obj, _register_update_chan, _merge_diff from tqsdk.entity import Entity from tqsdk.objs import Quote from tqsdk.tradeable.tradeable import Tradeable -from tqsdk.tradeable.sim.trade import SimTrade +from tqsdk.tradeable.sim.trade_future import SimTrade +from tqsdk.tradeable.sim.trade_stock import SimTradeStock from tqsdk.utils import _query_for_quote class BaseSim(Tradeable): - def __init__(self, broker_id, account_id, init_balance, trade_class: Type[SimTrade]) -> None: - - super(BaseSim, self).__init__(broker_id=broker_id, account_id=account_id) + def __init__(self, account_id, init_balance, trade_class: Union[Type[SimTrade], Type[SimTradeStock]]) -> None: + self._account_id = account_id + super(BaseSim, self).__init__() self.trade_log = {} # 日期->交易记录及收盘时的权益及持仓 self.tqsdk_stat = {} # 回测结束后储存回测报告信息 @@ -33,6 +34,7 @@ def __init__(self, broker_id, account_id, init_balance, trade_class: Type[SimTra self._trading_day_end = "1990-01-01 18:00:00.000000" self._local_time_record = float("nan") # 记录获取最新行情时的本地时间 self._sim_trade = trade_class(account_key=self._account_key, + account_id=self._account_id, init_balance=self._init_balance, get_trade_timestamp=self._get_trade_timestamp, is_in_trading_time=self._is_in_trading_time) @@ -45,6 +47,18 @@ def __init__(self, broker_id, account_id, init_balance, trade_class: Type[SimTra } self._quote_tasks = {} + @property + def _account_name(self): + return self._account_id + + @property + def _account_info(self): + info = super(BaseSim, self)._account_info + info.update({ + "account_id": self._account_id + }) + return info + async def _run(self, api, api_send_chan, api_recv_chan, md_send_chan, md_recv_chan): """模拟交易task""" self._api = api diff --git a/tqsdk/tradeable/sim/tqsim.py b/tqsdk/tradeable/sim/tqsim.py index 763cf6da..08a319a9 100644 --- a/tqsdk/tradeable/sim/tqsim.py +++ b/tqsdk/tradeable/sim/tqsim.py @@ -3,17 +3,17 @@ __author__ = 'mayanqiong' -from tqsdk.tradeable.interface import IFuture +from tqsdk.tradeable.mixin import FutureMixin from tqsdk.datetime import _format_from_timestamp_nano from tqsdk.diff import _get_obj from tqsdk.objs import Quote from tqsdk.report import TqReport from tqsdk.tradeable.sim.basesim import BaseSim -from tqsdk.tradeable.sim.trade import SimTrade +from tqsdk.tradeable.sim.trade_future import SimTrade from tqsdk.tradeable.sim.utils import _get_future_margin, _get_commission -class TqSim(BaseSim, IFuture): +class TqSim(BaseSim, FutureMixin): """ 天勤模拟交易类 @@ -42,11 +42,18 @@ def __init__(self, init_balance: float = 10000000.0, account_id: str = None) -> """ if float(init_balance) <= 0: raise Exception("初始资金(init_balance) %s 错误, 请检查 init_balance 是否填写正确" % (init_balance)) - super(TqSim, self).__init__(broker_id="TQSIM", - account_id="TQSIM" if account_id is None else account_id, + super(TqSim, self).__init__(account_id="TQSIM" if account_id is None else account_id, init_balance=float(init_balance), trade_class=SimTrade) + @property + def _account_info(self): + info = super(TqSim, self)._account_info + info.update({ + "account_type": self._account_type + }) + return info + def set_commission(self, symbol: str, commission: float=float('nan')): """ 设置指定合约模拟交易的每手手续费。 @@ -177,7 +184,7 @@ def _handle_on_alive(self, msg, order): """ symbol = f"{order['exchange_id']}.{order['instrument_id']}" self._api._print( - f"模拟交易下单 {order['order_id']}: 时间: {_format_from_timestamp_nano(order['insert_date_time'])}, " + f"模拟交易下单 {self._account_name}, {order['order_id']}: 时间: {_format_from_timestamp_nano(order['insert_date_time'])}, " f"合约: {symbol}, 开平: {order['offset']}, 方向: {order['direction']}, 手数: {order['volume_left']}, " f"价格: {order.get('limit_price', '市价')}") self._logger.debug(msg, order_id=order["order_id"], datetime=order["insert_date_time"], @@ -188,7 +195,7 @@ def _handle_on_finished(self, msg, order): """ 在 order 状态变为 FINISHED 调用,屏幕输出信息,打印日志 """ - self._api._print(f"模拟交易委托单 {order['order_id']}: {order['last_msg']}") + self._api._print(f"模拟交易委托单 {self._account_name}, {order['order_id']}: {order['last_msg']}") self._logger.debug(msg, order_id=order["order_id"], last_msg=order["last_msg"], status=order["status"], volume_orign=order["volume_orign"], volume_left=order["volume_left"]) @@ -196,7 +203,7 @@ def _report(self): if not self.trade_log: return date_keys = sorted(self.trade_log.keys()) - self._api._print("模拟交易成交记录") + self._api._print(f"模拟交易成交记录, 账户: {self._account_name}") for d in date_keys: for t in self.trade_log[d]["trades"]: symbol = t["exchange_id"] + "." + t["instrument_id"] @@ -204,7 +211,7 @@ def _report(self): f"开平: {t['offset']}, 方向: {t['direction']}, 手数: {t['volume']}, 价格: {t['price']:.3f}," f"手续费: {t['commission']:.2f}") - self._api._print("模拟交易账户资金") + self._api._print(f"模拟交易账户资金, 账户: {self._account_name}") for d in date_keys: account = self.trade_log[d]["account"] self._api._print( diff --git a/tqsdk/tradeable/sim/tqsim_stock.py b/tqsdk/tradeable/sim/tqsim_stock.py new file mode 100644 index 00000000..be3a1589 --- /dev/null +++ b/tqsdk/tradeable/sim/tqsim_stock.py @@ -0,0 +1,161 @@ +#!usr/bin/env python3 +# -*- coding:utf-8 -*- + +__author__ = 'mayanqiong' + +from tqsdk.tradeable.mixin import StockMixin +from tqsdk.datetime import _format_from_timestamp_nano +from tqsdk.report import TqReport +from tqsdk.tradeable.sim.basesim import BaseSim +from tqsdk.tradeable.sim.trade_stock import SimTradeStock + + +class TqSimStock(BaseSim, StockMixin): + """ + 天勤股票模拟交易类 + + 该类实现了一个本地的股票模拟交易账户,并且在内部完成撮合交易,在回测模式下,只能使用 TqSimStock 账户来交易股票合约。 + + 股票模拟交易只支持 ins_class 字段为 'STOCK' 的合约,且不支持 T+0 交易。 + + 限价单要求报单价格达到或超过对手盘价格才能成交, 成交价为报单价格, 如果没有对手盘(涨跌停)则无法成交 + + 市价单使用对手盘价格成交, 如果没有对手盘(涨跌停)则自动撤单 + + 模拟交易不会有部分成交的情况, 要成交就是全部成交 + + TqSimStock 暂不支持设置手续费 + """ + + def __init__(self, init_balance: float = 10000000.0, account_id: str = None) -> None: + """ + Args: + init_balance (float): [可选]初始资金, 默认为一千万 + + account_id (str): [可选]帐号, 默认为 TQSIM_STOCK + + Example1:: + + # 修改TqSim模拟帐号的初始资金为100000 + from tqsdk import TqApi, TqSimStock, TqAuth + api = TqApi(TqSimStock(init_balance=100000), auth=TqAuth("信易账户", "账户密码")) + + Example2:: + + # 同时使用 TqSim 交易期货,TqSimStock 交易股票 + from tqsdk import TqApi, TqAuth, TqMultiAccount, TqSim, TqSimStock + + tqsim_future = TqSim() + tqsim_stock = TqSimStock() + + api = TqApi(account=TqMultiAccount([tqsim_future, tqsim_stock]), auth=TqAuth("信易账户", "账户密码")) + + # 多账户下单,需要指定下单账户 + order1 = api.insert_order(symbol="SHFE.cu2112", direction="BUY", offset="OPEN", volume=10, limit_price=72250.0, account=tqsim_future) + order2 = api.insert_order(symbol="SSE.603666", direction="BUY", volume=300, account=tqsim_stock) + while order1.status != 'FINISHED' or order2.status != 'FINISHED': + api.wait_update() + + # 打印账户可用资金 + future_account = tqsim_future.get_account() + stock_account = tqsim_stock.get_account() + print(future_account.available, stock_account.available) + api.close() + + Example3:: + + # 在回测模式下,同时使用 TqSim 交易期货,TqSimStock 交易股票 + api = TqApi(account=TqMultiAccount([tqsim_future, tqsim_stock]), + backtest=TqBacktest(start_dt=datetime(2021, 7, 12), end_dt=datetime(2021, 7, 14)), + auth=TqAuth("信易账户", "账户密码")) + + future_account = api.get_account(tqsim_future) + stock_account = api.get_account(tqsim_stock) + + future_quote = api.get_quote("SHFE.cu2112") + future_stock = api.get_quote("SSE.603666") + + while datetime.strptime(future_stock.datetime, "%Y-%m-%d %H:%M:%S.%f") < datetime(2021, 7, 12, 9, 50): + api.wait_update() + + # 开仓,多账户下单,需要指定下单账户 + order1 = api.insert_order(symbol="SHFE.cu2112", direction="BUY", offset="OPEN", volume=10, limit_price=future_quote.ask_price1, account=tqsim_future) + order2 = api.insert_order(symbol="SSE.603666", direction="BUY", volume=300, account=tqsim_stock) + while order1.status != 'FINISHED' or order2.status != 'FINISHED': + api.wait_update() + + # 等待行情回测到第二天 + while datetime.strptime(future_stock.datetime, "%Y-%m-%d %H:%M:%S.%f") < datetime(2021, 7, 13, 10, 30): + api.wait_update() + # 平仓,股票只能 T+1 交易 + order3 = api.insert_order(symbol="SHFE.cu2112", direction="SELL", offset="CLOSE", volume=8, limit_price=future_quote.bid_price1, account=tqsim_future) + order4 = api.insert_order(symbol="SSE.603666", direction="SELL", volume=200, account=tqsim_stock) + while order3.status != 'FINISHED' or order4.status != 'FINISHED': + api.wait_update() + + try: # 等到回测结束 + while True: + api.wait_update() + except BacktestFinished: + api.close() + + """ + if float(init_balance) <= 0: + raise Exception("初始资金(init_balance) %s 错误, 请检查 init_balance 是否填写正确" % (init_balance)) + super(TqSimStock, self).__init__(account_id="TQSIM_STOCK" if account_id is None else account_id, + init_balance=float(init_balance), + trade_class=SimTradeStock) + + @property + def _account_info(self): + info = super(TqSimStock, self)._account_info + info.update({ + "account_type": self._account_type + }) + return info + + def _handle_on_alive(self, msg, order): + """ + 在 order 状态变为 ALIVE 调用,屏幕输出信息,打印日志 + """ + symbol = f"{order['exchange_id']}.{order['instrument_id']}" + self._api._print( + f"模拟交易下单 {self._account_name}, {order['order_id']}: 时间: {_format_from_timestamp_nano(order['insert_date_time'])}, " + f"合约: {symbol}, 方向: {order['direction']}, 手数: {order['volume_left']}, " + f"价格: {order.get('limit_price', '市价')}") + self._logger.debug(msg, order_id=order["order_id"], datetime=order["insert_date_time"], + symbol=symbol, direction=order["direction"], + volume_left=order["volume_left"], limit_price=order.get("limit_price", "市价")) + + def _handle_on_finished(self, msg, order): + """ + 在 order 状态变为 FINISHED 调用,屏幕输出信息,打印日志 + """ + self._api._print(f"模拟交易委托单 {self._account_name}, {order['order_id']}: {order['last_msg']}") + self._logger.debug(msg, order_id=order["order_id"], last_msg=order["last_msg"], status=order["status"], + volume_orign=order["volume_orign"], volume_left=order["volume_left"]) + + def _report(self): + if not self.trade_log: + return + date_keys = sorted(self.trade_log.keys()) + self._api._print(f"模拟交易成交记录, 账户: {self._account_name}") + for d in date_keys: + for t in self.trade_log[d]["trades"]: + symbol = t["exchange_id"] + "." + t["instrument_id"] + self._api._print(f"时间: {_format_from_timestamp_nano(t['trade_date_time'])}, 合约: {symbol}, " + f"方向: {t['direction']}, 手数: {t['volume']}, 价格: {t['price']:.3f}, 手续费: {t['fee']:.2f}") + + self._api._print(f"模拟交易账户资金, 账户: {self._account_name}") + for d in date_keys: + account = self.trade_log[d]["account"] + self._api._print( + f"日期: {d}, 账户资产: {account['asset']:.2f}, 分红: {account['dividend_balance_today']:.2f}, " + f"买入成本: {account['cost']:.2f}, 盈亏: {account['profit_today']:.2f}, 盈亏比: {account['profit_rate_today']:.2f}, " + f"手续费: {account['buy_fee_today'] + account['sell_fee_today']:.2f}") + report = TqReport(report_id=self._account_id, trade_log=self.trade_log, quotes=self._data['quotes'], account_type="SPOT") + self.tqsdk_stat = report.default_metrics + self._api._print( + f"收益率: {self.tqsdk_stat['ror'] * 100:.2f}%, 年化收益率: {self.tqsdk_stat['annual_yield'] * 100:.2f}%, " + f"最大回撤: {self.tqsdk_stat['max_drawdown'] * 100:.2f}%, 年化夏普率: {self.tqsdk_stat['sharpe_ratio']:.4f}," + f"年化索提诺比率: {self.tqsdk_stat['sortino_ratio']:.4f}") diff --git a/tqsdk/tradeable/sim/trade_base.py b/tqsdk/tradeable/sim/trade_base.py index 803ad004..db8fb5b1 100644 --- a/tqsdk/tradeable/sim/trade_base.py +++ b/tqsdk/tradeable/sim/trade_base.py @@ -38,9 +38,10 @@ class SimTradeBase(object): """ - def __init__(self, account_key: str, init_balance: float = 10000000.0, get_trade_timestamp: Callable = None, - is_in_trading_time: Callable = None) -> None: + def __init__(self, account_key: str, account_id: str = "", init_balance: float = 10000000.0, + get_trade_timestamp: Callable = None, is_in_trading_time: Callable = None) -> None: self._account_key = account_key + self._account_id = account_id self._quotes = {} # 会记录所有的发来的行情 # 初始化账户结构 self._account = self._generate_account(init_balance) @@ -163,13 +164,13 @@ def _match_order(self, order, symbol, position, quote, underlying_quote=None): assert order["status"] == "ALIVE" status, last_msg, price = SimTradeBase.match_order(order, quote) if status == "FINISHED": + order["last_msg"] = last_msg + order["status"] = status if last_msg == "全部成交": trade = self._generate_trade(order, quote, price) self._trades.append(trade) self._on_order_traded(order, trade, symbol, position, quote, underlying_quote) else: - order["last_msg"] = last_msg - order["status"] = status self._on_order_failed(symbol, order) # 成交后记录 orders_event, 删除 order self._orders_events.append(order) @@ -253,7 +254,7 @@ def match_order(order, quote) -> (str, str, float): price = order["limit_price"] if order["price_type"] == "ANY" and math.isnan(price): status, last_msg = "FINISHED", "市价指令剩余撤销" - if order["time_condition"] == "IOC": # IOC 立即成交,限价下单且不能成交的价格,直接撤单 + if order.get("time_condition") == "IOC": # IOC 立即成交,限价下单且不能成交的价格,直接撤单 if order["direction"] == "BUY" and price < ask_price or order["direction"] == "SELL" and price > bid_price: status, last_msg = "FINISHED", "已撤单报单已提交" if order["direction"] == "BUY" and price >= ask_price or order["direction"] == "SELL" and price <= bid_price: diff --git a/tqsdk/tradeable/sim/trade_future.py b/tqsdk/tradeable/sim/trade_future.py new file mode 100644 index 00000000..713e7ec4 --- /dev/null +++ b/tqsdk/tradeable/sim/trade_future.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = 'mayanqiong' + +import math + +from tqsdk.tradeable.sim.trade_base import SimTradeBase +from tqsdk.tradeable.sim.utils import _get_option_margin, _get_premium, _get_close_profit, _get_commission, _get_future_margin + + +class SimTrade(SimTradeBase): + """ + 天勤模拟交易账户,期货及商品期权 + """ + + def _generate_account(self, init_balance): + return { + "currency": "CNY", + "pre_balance": init_balance, + "static_balance": init_balance, + "balance": init_balance, + "available": init_balance, + "float_profit": 0.0, + "position_profit": 0.0, # 期权没有持仓盈亏 + "close_profit": 0.0, + "frozen_margin": 0.0, + "margin": 0.0, + "frozen_commission": 0.0, + "commission": 0.0, + "frozen_premium": 0.0, + "premium": 0.0, + "deposit": 0.0, + "withdraw": 0.0, + "risk_ratio": 0.0, + "market_value": 0.0, + "ctp_balance": float("nan"), + "ctp_available": float("nan") + } + + def _generate_position(self, symbol, quote, underlying_quote) -> dict: + return { + "exchange_id": symbol.split(".", maxsplit=1)[0], + "instrument_id": symbol.split(".", maxsplit=1)[1], + "pos_long_his": 0, + "pos_long_today": 0, + "pos_short_his": 0, + "pos_short_today": 0, + "volume_long_today": 0, + "volume_long_his": 0, + "volume_long": 0, + "volume_long_frozen_today": 0, + "volume_long_frozen_his": 0, + "volume_long_frozen": 0, + "volume_short_today": 0, + "volume_short_his": 0, + "volume_short": 0, + "volume_short_frozen_today": 0, + "volume_short_frozen_his": 0, + "volume_short_frozen": 0, + "open_price_long": float("nan"), + "open_price_short": float("nan"), + "open_cost_long": 0.0, + "open_cost_short": 0.0, + "position_price_long": float("nan"), + "position_price_short": float("nan"), + "position_cost_long": 0.0, + "position_cost_short": 0.0, + "float_profit_long": 0.0, + "float_profit_short": 0.0, + "float_profit": 0.0, + "position_profit_long": 0.0, + "position_profit_short": 0.0, + "position_profit": 0.0, + "margin_long": 0.0, + "margin_short": 0.0, + "margin": 0.0, + "last_price": quote["last_price"], + "underlying_last_price": underlying_quote["last_price"] if underlying_quote else float("nan"), + "market_value_long": 0.0, # 权利方市值(始终 >= 0) + "market_value_short": 0.0, # 义务方市值(始终 <= 0) + "market_value": 0.0, + "future_margin": _get_future_margin(quote), + } + + def _generate_order(self, pack: dict) -> dict: + """order 对象预处理""" + order = pack.copy() + order["exchange_order_id"] = order["order_id"] + order["volume_orign"] = order["volume"] + order["volume_left"] = order["volume"] + order["frozen_margin"] = 0.0 + order["frozen_premium"] = 0.0 + order["last_msg"] = "报单成功" + order["status"] = "ALIVE" + order["insert_date_time"] = self._get_trade_timestamp() + del order["aid"] + del order["volume"] + self._append_to_diffs(['orders', order["order_id"]], order) + return order + + def _generate_trade(self, order, quote, price) -> dict: + trade_id = order["order_id"] + "|" + str(order["volume_left"]) + return { + "user_id": order["user_id"], + "order_id": order["order_id"], + "trade_id": trade_id, + "exchange_trade_id": order["order_id"] + "|" + str(order["volume_left"]), + "exchange_id": order["exchange_id"], + "instrument_id": order["instrument_id"], + "direction": order["direction"], + "offset": order["offset"], + "price": price, + "volume": order["volume_left"], + "trade_date_time": self._get_trade_timestamp(), # todo: 可能导致测试结果不确定 + "commission": order["volume_left"] * _get_commission(quote) + } + + def _on_settle(self): + for symbol in self._orders: + for order in self._orders[symbol].values(): + order["frozen_margin"] = 0.0 + order["frozen_premium"] = 0.0 + order["last_msg"] = "交易日结束,自动撤销当日有效的委托单(GFD)" + order["status"] = "FINISHED" + self._append_to_diffs(['orders', order["order_id"]], order) + + # account 原始字段 + self._account["pre_balance"] = self._account["balance"] - self._account["market_value"] + self._account["close_profit"] = 0.0 + self._account["commission"] = 0.0 + self._account["premium"] = 0.0 + self._account["frozen_margin"] = 0.0 + self._account["frozen_premium"] = 0.0 + # account 计算字段 + self._account["static_balance"] = self._account["pre_balance"] + self._account["position_profit"] = 0.0 + self._account["risk_ratio"] = self._account["margin"] / self._account["balance"] + self._account["available"] = self._account["static_balance"] - self._account["margin"] + # 根据公式 账户权益 不需要计算 self._account["balance"] = static_balance + market_value + self._append_to_diffs(['accounts', 'CNY'], self._account) + + # 对于持仓的结算放在这里,没有放在 quote_handler 里的原因: + # 1. 异步发送的话,会造成如果此时 sim 未收到 pending_peek, 就没法把结算的账户信息发送出去,此时用户代码中 api.get_postion 得到的持仓和 sim 里面的持仓是不一致的 + # set_target_pos 下单时就会产生错单。而且结算时一定是已经收到过行情的数据包,在同步代码的最后一步,会发送出去这个行情包 peeding_peek, + # quote_handler 处理 settle 的时候, 所以在结算的时候 pending_peek 一定是 False, 要 api 处理过之后,才会收到 peek_message + # 2. 同步发送的话,就可以和产生切换交易日的数据包同时发送出去 + # 对 order 的处理发生在下一次回复 peek_message + for position in self._positions.values(): + symbol = f"{position['exchange_id']}.{position['instrument_id']}" + # position 原始字段 + position["volume_long_frozen_today"] = 0 + position["volume_long_frozen_his"] = 0 + position["volume_short_frozen_today"] = 0 + position["volume_short_frozen_his"] = 0 + position["volume_long_today"] = 0 + position["volume_long_his"] = position["volume_long"] + position["volume_short_today"] = 0 + position["volume_short_his"] = position["volume_short"] + # position 计算字段 + position["pos_long_his"] = position["volume_long_his"] + position["pos_long_today"] = 0 + position["pos_short_his"] = position["volume_short_his"] + position["pos_short_today"] = 0 + position["volume_long_frozen"] = 0 + position["volume_short_frozen"] = 0 + position["position_price_long"] = position["last_price"] + position["position_price_short"] = position["last_price"] + quote, _ = self._get_quotes_by_symbol(symbol) + position["position_cost_long"] = position["last_price"] * position["volume_long"] * quote["volume_multiple"] # position 原始字段 + position["position_cost_short"] = position["last_price"] * position["volume_short"] * quote["volume_multiple"] # position 原始字段 + position["position_profit_long"] = 0 + position["position_profit_short"] = 0 + position["position_profit"] = 0 + self._append_to_diffs(['positions', symbol], position) + + def _check_insert_order(self, order, symbol, position, quote, underlying_quote=None): + # 无法计入 orderbook, 各种账户都需要判断的 + if ("commission" not in quote or "margin" not in quote) and not quote["ins_class"].endswith("OPTION"): + order["last_msg"] = "不支持的合约类型,TqSim 目前不支持组合,股票,etf期权模拟交易" + order["status"] = "FINISHED" + if order["status"] == "ALIVE" and not self._is_in_trading_time(quote): + order["last_msg"] = "下单失败, 不在可交易时间段内" + order["status"] = "FINISHED" + if order["status"] == "ALIVE" and order["offset"].startswith('CLOSE'): + if order["exchange_id"] in ["SHFE", "INE"]: + if order["offset"] == "CLOSETODAY": + if order["direction"] == "BUY" and position["volume_short_today"] - position["volume_long_frozen_today"] < order["volume_orign"]: + order["last_msg"] = "平今仓手数不足" + elif order["direction"] == "SELL" and position["volume_long_today"] - position["volume_long_frozen_today"] < order["volume_orign"]: + order["last_msg"] = "平今仓手数不足" + if order["offset"] == "CLOSE": + if order["direction"] == "BUY" and position["volume_short_his"] - position["volume_short_frozen_his"] < order["volume_orign"]: + order["last_msg"] = "平昨仓手数不足" + elif order["direction"] == "SELL" and position["volume_long_his"] - position["volume_long_frozen_his"] < order["volume_orign"]: + order["last_msg"] = "平昨仓手数不足" + else: + if order["direction"] == "BUY" and position["volume_short"] - position["volume_short_frozen"] < order["volume_orign"]: + order["last_msg"] = "平仓手数不足" + elif order["direction"] == "SELL" and position["volume_long"] - position["volume_long_frozen"] < order["volume_orign"]: + order["last_msg"] = "平仓手数不足" + if order["last_msg"].endswith("手数不足"): + order["status"] = "FINISHED" + + if order["status"] == "ALIVE" and order["offset"] == "OPEN": + # 计算冻结保证金,冻结权利金 + if quote["ins_class"].endswith("OPTION"): + if order["direction"] == "SELL": # 期权的SELL义务仓,开仓需要冻结保证金 + order["frozen_margin"] = order["volume_orign"] * _get_option_margin(quote, quote["last_price"], underlying_quote["last_price"]) + else: # 期权的BUY权利仓(市价单使用 last_price 计算需要冻结的权利金) + price = quote["last_price"] if order["price_type"] == "ANY" else order["limit_price"] + order["frozen_premium"] = order["volume_orign"] * quote["volume_multiple"] * price + else: + order["frozen_margin"] = order["volume_orign"] * _get_future_margin(quote) + if order["frozen_margin"] + order["frozen_premium"] > self._account["available"]: + order["frozen_margin"] = 0.0 + order["frozen_premium"] = 0.0 + order["last_msg"] = '开仓资金不足' + order["status"] = "FINISHED" + if order["status"] == "FINISHED": + self._append_to_diffs(['orders', order["order_id"]], order) + + def _on_insert_order(self, order, symbol, position, quote, underlying_quote=None): + """判断 order 是否可以记录在 orderbook""" + if order["offset"] == "OPEN": + # 修改 account 计算字段 + self._adjust_account_by_order(frozen_margin=order["frozen_margin"], frozen_premium=order["frozen_premium"]) + self._append_to_diffs(['accounts', 'CNY'], self._account) + else: + # 修改 position 原始字段 + if order["exchange_id"] in ["SHFE", "INE"]: + if order["direction"] == "BUY": + position[f"volume_short_frozen_{'today' if order['offset'] == 'CLOSETODAY' else 'his'}"] += order["volume_orign"] + else: + position[f"volume_long_frozen_{'today' if order['offset'] == 'CLOSETODAY' else 'his'}"] += order["volume_orign"] + elif order["direction"] == "BUY": + volume_short_his_available = position["volume_short_his"] - position["volume_short_frozen_his"] + if volume_short_his_available < order["volume_orign"]: + position["volume_short_frozen_his"] += volume_short_his_available + position["volume_short_frozen_today"] += order["volume_orign"] - volume_short_his_available + else: + position["volume_short_frozen_his"] += order["volume_orign"] + else: + volume_long_his_available = position["volume_long_his"] - position["volume_long_frozen_his"] + if volume_long_his_available < order["volume_orign"]: + position["volume_long_frozen_his"] += volume_long_his_available + position["volume_long_frozen_today"] += order["volume_orign"] - volume_long_his_available + else: + position["volume_long_frozen_his"] += order["volume_orign"] + # 修改 position 计算字段 + self._adjust_position_volume_frozen(position) + self._append_to_diffs(['positions', symbol], position) + + def _on_order_traded(self, order, trade, symbol, position, quote, underlying_quote): + origin_frozen_margin = order["frozen_margin"] + origin_frozen_premium = order["frozen_premium"] + order["frozen_margin"] = 0.0 + order["frozen_premium"] = 0.0 + order["volume_left"] = 0 + self._append_to_diffs(['trades', trade["trade_id"]], trade) + self._append_to_diffs(['orders', order["order_id"]], order) + + if order["offset"] == 'OPEN': + if order["direction"] == "BUY": + # 修改 position 原始字段 + position["volume_long_today"] += order["volume_orign"] + position["open_cost_long"] += trade["price"] * order["volume_orign"] * quote["volume_multiple"] # 多头开仓成本 + position["position_cost_long"] += trade["price"] * order["volume_orign"] * quote["volume_multiple"] # 多头持仓成本 + else: + # 修改 position 原始字段 + position["volume_short_today"] += order["volume_orign"] + position["open_cost_short"] += trade["price"] * order["volume_orign"] * quote["volume_multiple"] # 空头开仓成本 + position["position_cost_short"] += trade["price"] * order["volume_orign"] * quote["volume_multiple"] # 空头持仓成本 + + # 由 order 变化,account 需要更新的计算字段 + self._adjust_account_by_order(frozen_margin=-origin_frozen_margin, frozen_premium=-origin_frozen_premium) + + # 由 trade 引起的 account 原始字段变化,account 需要更新的计算字段 + premium = _get_premium(trade, quote) + self._adjust_account_by_trade(commission=trade["commission"], premium=premium) + + # 由 position 字段变化,同时 account 需要更新的计算字段 + buy_open = order["volume_orign"] if order["direction"] == "BUY" else 0 + sell_open = 0 if order["direction"] == "BUY" else order["volume_orign"] + self._adjust_position_account(symbol, quote, underlying_quote, + pre_last_price=trade["price"], + last_price=position["last_price"], + pre_underlying_last_price=underlying_quote["last_price"] if underlying_quote else float('nan'), + underlying_last_price=position["underlying_last_price"], + buy_open=buy_open, sell_open=sell_open) + + else: # order["offset"].startswith('CLOSE') + # 修改 position 原始字段 + if order["exchange_id"] in ["SHFE", "INE"]: + if order["offset"] == "CLOSETODAY": + if order["direction"] == "BUY": + position["volume_short_frozen_today"] -= order["volume_orign"] + position["volume_short_today"] -= order["volume_orign"] + elif order["direction"] == "SELL": + position["volume_long_frozen_today"] -= order["volume_orign"] + position["volume_long_today"] -= order["volume_orign"] + if order["offset"] == "CLOSE": + if order["direction"] == "BUY": + position["volume_short_frozen_his"] -= order["volume_orign"] + position["volume_short_his"] -= order["volume_orign"] + elif order["direction"] == "SELL": + position["volume_long_frozen_his"] -= order["volume_orign"] + position["volume_long_his"] -= order["volume_orign"] + elif order["direction"] == "BUY": + if position["volume_short_frozen_his"] >= order["volume_orign"]: + position["volume_short_frozen_his"] -= order["volume_orign"] + position["volume_short_his"] -= order["volume_orign"] + else: + position["volume_short_frozen_today"] -= order["volume_orign"] - position["volume_short_frozen_his"] + position["volume_short_today"] -= order["volume_orign"] - position["volume_short_frozen_his"] + position["volume_short_his"] -= position["volume_short_frozen_his"] + position["volume_short_frozen_his"] = 0 + else: + if position["volume_long_frozen_his"] >= order["volume_orign"]: + position["volume_long_frozen_his"] -= order["volume_orign"] + position["volume_long_his"] -= order["volume_orign"] + else: + position["volume_long_frozen_today"] -= order["volume_orign"] - position["volume_long_frozen_his"] + position["volume_long_today"] -= order["volume_orign"] - position["volume_long_frozen_his"] + position["volume_long_his"] -= position["volume_long_frozen_his"] + position["volume_long_frozen_his"] = 0 + + # 修改 position 原始字段 + if order["direction"] == "SELL": + position["open_cost_long"] -= position["open_price_long"] * order["volume_orign"] * quote["volume_multiple"] # 多头开仓成本 + position["position_cost_long"] -= position["position_price_long"] * order["volume_orign"] * quote["volume_multiple"] # 多头持仓成本 + else: + position["open_cost_short"] -= position["open_price_short"] * order["volume_orign"] * quote["volume_multiple"] # 空头开仓成本 + position["position_cost_short"] -= position["position_price_short"] * order["volume_orign"] * quote["volume_multiple"] # 空头持仓成本 + + # 由 trade 引起的 account 原始字段变化,account 需要更新的计算字段 + premium = _get_premium(trade, quote) + close_profit = _get_close_profit(trade, quote, position) + self._adjust_account_by_trade(commission=trade["commission"], premium=premium, close_profit=close_profit) + + # 由 position 字段变化,同时 account 需要更新的计算字段 + buy_close = order["volume_orign"] if order["direction"] == "BUY" else 0 + sell_close = 0 if order["direction"] == "BUY" else order["volume_orign"] + self._adjust_position_account(symbol, quote, underlying_quote, pre_last_price=position["last_price"], + last_price=0, pre_underlying_last_price=position["underlying_last_price"], + underlying_last_price=0, buy_close=buy_close, sell_close=sell_close) + self._append_to_diffs(['positions', symbol], position) + self._append_to_diffs(['accounts', 'CNY'], self._account) + + def _on_order_failed(self, symbol, order): + origin_frozen_margin = order["frozen_margin"] + origin_frozen_premium = order["frozen_premium"] + order["frozen_margin"] = 0.0 + order["frozen_premium"] = 0.0 + self._append_to_diffs(['orders', order["order_id"]], order) + + # 调整账户和持仓 + if order["offset"] == 'OPEN': + self._adjust_account_by_order(frozen_margin=-origin_frozen_margin, frozen_premium=-origin_frozen_premium) + self._append_to_diffs(['accounts', 'CNY'], self._account) + else: + position = self._positions[symbol] + if order["exchange_id"] in ["SHFE", "INE"]: + if order["offset"] == "CLOSETODAY": + if order["direction"] == "BUY": + position["volume_short_frozen_today"] -= order["volume_orign"] + else: + position["volume_long_frozen_today"] -= order["volume_orign"] + if order["offset"] == "CLOSE": + if order["direction"] == "BUY": + position["volume_short_frozen_his"] -= order["volume_orign"] + else: + position["volume_long_frozen_his"] -= order["volume_orign"] + else: + if order["direction"] == "BUY": + if position["volume_short_frozen_today"] >= order["volume_orign"]: + position["volume_short_frozen_today"] -= order["volume_orign"] + else: + position["volume_short_frozen_his"] -= order["volume_orign"] - position["volume_short_frozen_today"] + position["volume_short_frozen_today"] = 0 + else: + if position["volume_long_frozen_today"] >= order["volume_orign"]: + position["volume_long_frozen_today"] -= order["volume_orign"] + else: + position["volume_long_frozen_his"] -= order["volume_orign"] - position["volume_long_frozen_today"] + position["volume_long_frozen_today"] = 0 + self._adjust_position_volume_frozen(position) + self._append_to_diffs(['positions', symbol], position) + + def _on_update_quotes(self, symbol, position, quote, underlying_quote): + # 调整持仓保证金和盈亏 + underlying_last_price = underlying_quote["last_price"] if underlying_quote else float('nan') + future_margin = _get_future_margin(quote) + if position["volume_long"] > 0 or position["volume_short"] > 0: + if position["last_price"] != quote["last_price"] \ + or (math.isnan(future_margin) or future_margin != position["future_margin"]) \ + or (underlying_quote and ( + math.isnan(underlying_last_price) or underlying_last_price != position["underlying_last_price"])): + self._adjust_position_account(symbol, quote, underlying_quote, + pre_last_price=position["last_price"], + last_price=quote["last_price"], + pre_underlying_last_price=position["underlying_last_price"], + underlying_last_price=underlying_last_price) + position["future_margin"] = future_margin + position["last_price"] = quote["last_price"] + position["underlying_last_price"] = underlying_last_price + else: + # 修改辅助变量 + position["future_margin"] = future_margin + position["last_price"] = quote["last_price"] + position["underlying_last_price"] = underlying_last_price + self._append_to_diffs(['positions', symbol], position) # 一定要返回 position,下游会用到 future_margin 字段判断修改保证金是否成功 + self._append_to_diffs(['accounts', 'CNY'], self._account) + + def _adjust_position_account(self, symbol, quote, underlying_quote=None, pre_last_price=float('nan'), last_price=float('nan'), + pre_underlying_last_price=float('nan'), underlying_last_price=float('nan'), + buy_open=0, buy_close=0, sell_open=0, sell_close=0): + """ + 价格变化,使得 position 中的以下计算字段需要修改,这个函数计算出需要修改的差值部分,计算出差值部分修改 position、account + 有两种情况下调用 + 1. 委托单 FINISHED,且全部成交,分为4种:buy_open, buy_close, sell_open, sell_close + 2. 行情跳动 + """ + position = self._positions[symbol] + float_profit_long = 0 # 多头浮动盈亏 + float_profit_short = 0 # 空头浮动盈亏 + position_profit_long = 0 # 多头持仓盈亏,期权持仓盈亏为0 + position_profit_short = 0 # 空头持仓盈亏,期权持仓盈亏为0 + margin_long = 0 # 多头占用保证金 + margin_short = 0 # 空头占用保证金 + market_value_long = 0 # 期权权利方市值(始终 >= 0) + market_value_short = 0 # 期权义务方市值(始终 <= 0) + assert [buy_open, buy_close, sell_open, sell_close].count(0) >= 3 # 只有一个大于0, 或者都是0,表示价格变化导致的字段修改 + if buy_open > 0: + # 买开,pre_last_price 应该是成交价格,last_price 应该是 position['last_price'] + float_profit_long = (last_price - pre_last_price) * buy_open * quote["volume_multiple"] + if quote["ins_class"].endswith("OPTION"): + market_value_long = last_price * buy_open * quote["volume_multiple"] + else: + margin_long = buy_open * _get_future_margin(quote) + position_profit_long = (last_price - pre_last_price) * buy_open * quote["volume_multiple"] + elif sell_close > 0: + # 卖平,pre_last_price 应该是 position['last_price'],last_price 应该是 0 + float_profit_long = -position["float_profit_long"] / position["volume_long"] * sell_close + if quote["ins_class"].endswith("OPTION"): + market_value_long = -pre_last_price * sell_close * quote["volume_multiple"] + else: + margin_long = -sell_close * _get_future_margin(quote) + position_profit_long = -position["position_profit_long"] / position["volume_long"] * sell_close + elif sell_open > 0: + # 卖开 + float_profit_short = (pre_last_price - last_price) * sell_open * quote["volume_multiple"] + if quote["ins_class"].endswith("OPTION"): + market_value_short = -last_price * sell_open * quote["volume_multiple"] + margin_short = sell_open * _get_option_margin(quote, last_price, underlying_last_price) + else: + margin_short = sell_open * _get_future_margin(quote) + position_profit_short = (pre_last_price - last_price) * sell_open * quote["volume_multiple"] + elif buy_close > 0: + # 买平 + float_profit_short = -position["float_profit_short"] / position["volume_short"] * buy_close + if quote["ins_class"].endswith("OPTION"): + market_value_short = pre_last_price * buy_close * quote["volume_multiple"] + margin_short = -buy_close * _get_option_margin(quote, pre_last_price, pre_underlying_last_price) + else: + margin_short = -buy_close * _get_future_margin(quote) + position_profit_short = -position["position_profit_short"] / position["volume_short"] * buy_close + else: + float_profit_long = (last_price - pre_last_price) * position["volume_long"] * quote["volume_multiple"] # 多头浮动盈亏 + float_profit_short = (pre_last_price - last_price) * position["volume_short"] * quote["volume_multiple"] # 空头浮动盈亏 + if quote["ins_class"].endswith("OPTION"): + margin_short = _get_option_margin(quote, last_price, underlying_last_price) * position["volume_short"] - position["margin_short"] + market_value_long = (last_price - pre_last_price) * position["volume_long"] * quote["volume_multiple"] + market_value_short = (pre_last_price - last_price) * position["volume_short"] * quote["volume_multiple"] + else: + # 期权持仓盈亏为 0 + position_profit_long = float_profit_long # 多头持仓盈亏 + position_profit_short = float_profit_short # 空头持仓盈亏 + margin_long = _get_future_margin(quote) * position["volume_long"] - position["margin_long"] + margin_short = _get_future_margin(quote) * position["volume_short"] - position["margin_short"] + + if any([buy_open, buy_close, sell_open, sell_close]): + # 修改 position volume 相关的计算字段 + # 在上面 sell_close buy_close 两种情况,计算浮动盈亏时,用到了修改前的手数,所以需改手数字段的代码放在这个位置 + self._adjust_position_volume(position) + + self._adjust_position(quote, position, float_profit_long, float_profit_short, position_profit_long, + position_profit_short, margin_long, margin_short, market_value_long, market_value_short) + self._adjust_account_by_position(float_profit=float_profit_long + float_profit_short, + position_profit=position_profit_long + position_profit_short, + margin=margin_long + margin_short, + market_value=market_value_long + market_value_short) + + # -------- 对于 position 的计算字段修改分为两类: + # 1. 针对手数相关的修改,在下单、成交时会修改 + # 2. 针对盈亏、保证金、市值的修改,由于参考合约最新价,在成交、行情跳动时会修改 + + def _adjust_position_volume_frozen(self, position): + """position 原始字段修改后,只有冻结手数需要重新计算,有两种情况需要调用 + 1. 下平仓单 2. 平仓单 FINISHED, 但没有成交 + """ + position["volume_long_frozen"] = position["volume_long_frozen_today"] + position["volume_long_frozen_his"] + position["volume_short_frozen"] = position["volume_short_frozen_today"] + position["volume_short_frozen_his"] + + def _adjust_position_volume(self, position): + """position 原始字段修改后,手数之后需要重新计算 + 1. 委托单 FINISHED,且全部成交 + """ + position["pos_long_today"] = position["volume_long_today"] + position["pos_long_his"] = position["volume_long_his"] + position["pos_short_today"] = position["volume_short_today"] + position["pos_short_his"] = position["volume_short_his"] + position["volume_long"] = position["volume_long_today"] + position["volume_long_his"] + position["volume_long_frozen"] = position["volume_long_frozen_today"] + position["volume_long_frozen_his"] + position["volume_short"] = position["volume_short_today"] + position["volume_short_his"] + position["volume_short_frozen"] = position["volume_short_frozen_today"] + position["volume_short_frozen_his"] + + def _adjust_position(self, quote, position, float_profit_long=0, float_profit_short=0, position_profit_long=0, + position_profit_short=0, margin_long=0, margin_short=0, market_value_long=0, + market_value_short=0): + # 更新 position 计算字段,根据差值更新的字段 + position["float_profit_long"] += float_profit_long + position["float_profit_short"] += float_profit_short + position["position_profit_long"] += position_profit_long + position["position_profit_short"] += position_profit_short + position["margin_long"] += margin_long + position["margin_short"] += margin_short + position["market_value_long"] += market_value_long + position["market_value_short"] += market_value_short + + # 更新 position 计算字段,原地重新计算的字段 + if position["volume_long"] > 0: + position["open_price_long"] = position["open_cost_long"] / position["volume_long"] / quote["volume_multiple"] + position["position_price_long"] = position["position_cost_long"] / position["volume_long"] / quote["volume_multiple"] + else: + position["open_price_long"] = float("nan") + position["position_price_long"] = float("nan") + if position["volume_short"] > 0: + position["open_price_short"] = position["open_cost_short"] / position["volume_short"] / quote["volume_multiple"] + position["position_price_short"] = position["position_cost_short"] / position["volume_short"] / quote["volume_multiple"] + else: + position["open_price_short"] = float("nan") + position["position_price_short"] = float("nan") + position["float_profit"] = position["float_profit_long"] + position["float_profit_short"] + position["position_profit"] = position["position_profit_long"] + position["position_profit_short"] + position["margin"] = position["margin_long"] + position["margin_short"] + position["market_value"] = position["market_value_long"] + position["market_value_short"] + + # -------- 对于 account 的修改分为以下三类 + + def _adjust_account_by_trade(self, commission=0, close_profit=0, premium=0): + """由成交引起的 account 原始字段变化,account 需要更新的计算字段""" + # account 原始字段 + self._account["close_profit"] += close_profit + self._account["commission"] += commission + self._account["premium"] += premium # premium变量的值有正负,正数表示收入的权利金,负数表示付出的权利金 + # account 计算字段 + self._account["balance"] += close_profit - commission + premium + self._account["available"] += close_profit - commission + premium + self._account["risk_ratio"] = self._account["margin"] / self._account["balance"] + + def _adjust_account_by_position(self, float_profit=0, position_profit=0, margin=0, market_value=0): + """由 position 变化,account 需要更新的计算字段""" + # account 计算字段,持仓字段求和的字段 + self._account["float_profit"] += float_profit + self._account["position_profit"] += position_profit + self._account["margin"] += margin + self._account["market_value"] += market_value + # account 计算字段 + self._account["balance"] += position_profit + market_value + self._account["available"] += position_profit - margin + self._account["risk_ratio"] = self._account["margin"] / self._account["balance"] + + def _adjust_account_by_order(self, frozen_margin=0, frozen_premium=0): + """由 order 变化,account 需要更新的计算字段""" + self._account["frozen_margin"] += frozen_margin + self._account["frozen_premium"] += frozen_premium + self._account["available"] -= (frozen_margin + frozen_premium) diff --git a/tqsdk/tradeable/sim/trade_stock.py b/tqsdk/tradeable/sim/trade_stock.py new file mode 100644 index 00000000..435ffbbf --- /dev/null +++ b/tqsdk/tradeable/sim/trade_stock.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "mayanqiong" + +from tqsdk.tradeable.sim.trade_base import SimTradeBase +from tqsdk.tradeable.sim.utils import _get_stock_fee, _get_order_price, _get_dividend_ratio + + +class SimTradeStock(SimTradeBase): + """ + 天勤模拟交易账户,期货及商品期权 + """ + + def _generate_account(self, init_balance): + return { + "user_id": self._account_id, # 客户号, 与 order / trade 对象中的 user_id 值保持一致 + "currency": "CNY", + "market_value_his": 0.0, # 期初市值 + "asset_his": init_balance, # 期初资产 + "cost_his": 0.0, # 期初买入成本 + "deposit": 0.0, + "withdraw": 0.0, + "dividend_balance_today": 0.0, # 当日分红金额 + + "available_his": init_balance, + + "market_value": 0.0, # 当前市值 + "asset": init_balance, # 当前资产 = 当前市值 + 可用余额 + 冻结 + "available": init_balance, # 可用余额 = 期初余额 + 当日分红金额 - 买入费用 - 卖出费用 + 当日入金 - 当日出金 - 当日买入占用资金 + 当日卖出释放资金 - 委托冻结金额 - 委托冻结费用 + "drawable": init_balance, # 可取余额 = 可用余额 - 当日卖出释放资金 + "buy_frozen_balance": 0.0, # 当前交易冻结金额(不含费用)= sum(order.volume_orign * order.limit_price) + "buy_frozen_fee": 0.0, # 当前交易冻结费用 = sum(order.frozen_fee) + "buy_balance_today": 0.0, # 当日买入占用资金(不含费用) + "buy_fee_today": 0.0, # 当日买入累计费用 + "sell_balance_today": 0.0, # 当日卖出释放资金 + "sell_fee_today": 0.0, # 当日卖出累计费用 + "cost": 0.0, # 当前买入成本 = SUM(买入成本) + "hold_profit": 0.0, # 当日持仓盈亏 = 当前市值 - 当前买入成本 + "float_profit_today": 0.0, # 当日浮动盈亏 = SUM(持仓当日浮动盈亏) + "real_profit_today": 0.0, # 当日实现盈亏 = SUM(持仓当日实现盈亏) + "profit_today": 0.0, # 当日盈亏 = 当日浮动盈亏 + 当日实现盈亏 + "profit_rate_today": 0.0 # 当日盈亏比 = 当日盈亏 / (当前买入成本 if 当前买入成本 > 0 else 期初资产) + } + + def _generate_position(self, symbol, quote, underlying_quote) -> dict: + return { + "user_id": self._account_id, + "exchange_id": symbol.split(".", maxsplit=1)[0], + "instrument_id": symbol.split(".", maxsplit=1)[1], + "create_date": "", # 建仓日期 + "volume_his": 0, # 昨持仓数量 + "cost_his": 0.0, # 期初买入成本 + "market_value_his": 0.0, # 期初市值 + "real_profit_his": 0.0, # 期初实现盈亏 + "shared_volume_today": 0, # 今送股数量 + "devidend_balance_today": 0.0, # 今分红金额 + + "buy_volume_his": 0, # 期初累计买入持仓 + "buy_balance_his": 0.0, # 期初累计买入金额 + "buy_fee_his": 0.0, # 期初累计买入费用 + "sell_volume_his": 0, # 期初累计卖出持仓 + "sell_balance_his": 0.0, # 期初累计卖出金额 + "sell_fee_his": 0.0, # 期初累计卖出费用 + + "buy_volume_today": 0, # 当日累计买入持仓 + "buy_balance_today": 0.0, # 当日累计买入金额 (不包括费用) + "buy_fee_today": 0.0, # 当日累计买入费用 + "sell_volume_today": 0, # 当日累计卖出持仓 + "sell_balance_today": 0.0, # 当日累计卖出金额 (不包括费用) + "sell_fee_today": 0.0, # 当日累计卖出费用 + + "last_price": quote["last_price"], + "sell_volume_frozen": 0, # 今日卖出冻结手数 + "sell_float_profit_today": 0.0, # 昨仓浮动盈亏 = (昨持仓数量 - 今卖数量) * (最新价 - 昨收盘价) + "buy_float_profit_today": 0.0, # 今仓浮动盈亏 = (今持仓数量 - (昨持仓数量 - 今卖数量)) * (最新价 - 买入均价) + # 买入均价 = (buy_balance_today + buy_fee_today) / buy_volume_today + + "cost": 0.0, # 当前成本 = 期初成本 + 今买金额 + 今买费用 - 今卖数量 × (期初买入成本 / 期初持仓数量) + "volume": 0, # 今持仓数量 = 昨持仓数量 + 今买数量 - 今卖数量 + 送股数量 + "market_value": 0.0, # 当前市值 = 持仓数量 × 行情最新价 + "float_profit_today": 0.0, # 当日浮动盈亏 = sell_float_profit_today + buy_float_profit_today + "real_profit_today": 0.0, # 当日实现盈亏 = 今卖数量 * (最新价 - 昨收盘价) - 今卖费用 + 今派息金额 + "profit_today": 0.0, # 当日盈亏 = 当日浮动盈亏 + 当日实现盈亏 + "profit_rate_today": 0.0, # 当日收益率 = 当日盈亏 / ( 当前成本 if 当前成本 > 0 else 期初市值) + "hold_profit": 0.0, # 当日持仓盈亏 = 当前市值 – 当前买入成本 + "real_profit_total": 0.0, # 累计实现盈亏 += 当日实现盈亏(成本) + "profit_total": 0.0, # 总盈亏 = 累计实现盈亏 + 持仓盈亏 + "profit_rate_total": 0.0, # 累计收益率 = 总盈亏 / (当前成本 if 当前成本 > 0 else 期初成本) + } + + def _generate_order(self, pack: dict) -> dict: + """order 对象预处理""" + order = pack.copy() + order["exchange_order_id"] = order["order_id"] + order["volume_orign"] = order["volume"] + order["volume_left"] = order["volume"] + order["frozen_balance"] = 0.0 + order["frozen_fee"] = 0.0 + order["last_msg"] = "报单成功" + order["status"] = "ALIVE" + order["insert_date_time"] = self._get_trade_timestamp() + del order["aid"] + del order["volume"] + self._append_to_diffs(["orders", order["order_id"]], order) + return order + + def _generate_trade(self, order, quote, price) -> dict: + fee = _get_stock_fee(order["direction"], order["volume_left"], price) + return { + "user_id": order["user_id"], + "order_id": order["order_id"], + "trade_id": order["order_id"] + "|" + str(order["volume_left"]), + "exchange_trade_id": order["order_id"] + "|" + str(order["volume_left"]), + "exchange_id": order["exchange_id"], + "instrument_id": order["instrument_id"], + "direction": order["direction"], # 下单方向, BUY=买, SELL=卖,SHARED=送股,DEVIDEND=分红 (送股|分红没有计算费用) + "price": price, + "volume": order["volume_left"], + "trade_date_time": self._get_trade_timestamp(), # todo: 可能导致测试结果不确定 + "fee": fee + } + + def _on_settle(self): + for symbol in self._orders: + for order in self._orders[symbol].values(): + order["frozen_balance"] = 0.0 + order["frozen_fee"] = 0.0 + order["last_msg"] = "交易日结束,自动撤销当日有效的委托单(GFD)" + order["status"] = "FINISHED" + self._append_to_diffs(["orders", order["order_id"]], order) + + dividend_balance_today = 0.0 # 今日分红总的分红数据 + for position in self._positions.values(): + symbol = f"{position['exchange_id']}.{position['instrument_id']}" + quote, _ = self._get_quotes_by_symbol(symbol) + stock_dividend, cash_dividend = _get_dividend_ratio(quote) + # position 原始字段 + position["volume_his"] = position["volume"] # 期初持仓数量 + position["cost_his"] = position["cost"] # 期初买入成本 + position["market_value_his"] = position["market_value"] # 期初市值 + position["real_profit_his"] = position["real_profit_today"] # 期初实现盈亏 + + # 处理分红送股 + position["shared_volume_today"] = stock_dividend * position["volume"] # 今送股数量 + position["devidend_balance_today"] = cash_dividend * position["volume"] # 今分红金额 + if position["shared_volume_today"] > 0.0 or position["devidend_balance_today"] > 0.0: + position["volume"] += position["shared_volume_today"] + position["market_value"] -= position["devidend_balance_today"] # 分红后的市值 + position["last_price"] = position["market_value"] / position["volume"] # 分红送股后的最新价, todo: 可能会于第二天收到的第一笔行情有误差? + dividend_balance_today += position["devidend_balance_today"] # 记录累积分红金额,account 需要 + + position["buy_volume_his"] = position["buy_volume_today"] + position["buy_balance_his"] = position["buy_balance_today"] + position["buy_fee_his"] = position["buy_fee_today"] + position["sell_volume_his"] = position["sell_volume_today"] + position["sell_balance_his"] = position["sell_balance_today"] + position["sell_fee_his"] = position["sell_fee_today"] + position["buy_volume_today"] = 0 + position["buy_balance_today"] = 0.0 + position["buy_fee_today"] = 0.0 + position["sell_volume_today"] = 0 + position["sell_balance_today"] = 0.0 + position["sell_fee_today"] = 0.0 + + position["sell_volume_frozen"] = 0 + position["buy_avg_price"] = 0.0 + position["sell_float_profit_today"] = 0.0 + position["buy_float_profit_today"] = 0.0 + + position["float_profit_today"] = 0.0 # 当日浮动盈亏 = position["sell_float_profit_today"] + position["buy_float_profit_today"] + position["real_profit_today"] = 0.0 # 当日实现盈亏 = 今卖数量 * (最新价 - 昨收盘价) - 今卖费用 + 今派息金额 + position["profit_today"] = 0.0 # 当日盈亏 = 当日浮动盈亏 + 当日实现盈亏 + position["profit_rate_today"] = 0.0 # 当日收益率 = 当日盈亏 / ( 当前成本 if 当前成本 > 0 else 期初市值) + position["hold_profit"] = 0.0 # 当日持仓盈亏 = 当前市值 – 当前买入成本 + self._append_to_diffs(["positions", symbol], position) + + # account 原始字段 + self._account["dividend_balance_today"] = dividend_balance_today + self._account["market_value_his"] = self._account["market_value"] + self._account["asset_his"] = self._account["asset"] + self._account["cost_his"] = self._account["cost"] + self._account["available_his"] = self._account["available"] + self._account["buy_frozen_balance"] + self._account["buy_frozen_fee"] + self._account["buy_frozen_balance"] = 0.0 + self._account["buy_frozen_fee"] = 0.0 + self._account["buy_balance_today"] = 0.0 + self._account["buy_fee_today"] = 0.0 + self._account["sell_balance_today"] = 0.0 + self._account["sell_fee_today"] = 0.0 + self._account["asset"] += self._account["dividend_balance_today"] + self._account["market_value"] -= self._account["dividend_balance_today"] + # account 计算字段 + self._account["available"] = self._account["asset"] - self._account["market_value"] # 当前可用余额 = 当前资产 - 当前市值 + self._account["drawable"] = self._account["available"] + self._account["hold_profit"] = 0.0 # 当日持仓盈亏 = 当前市值 - 当前买入成本 + self._account["float_profit_today"] = 0.0 # 当日浮动盈亏 = SUM(持仓当日浮动盈亏) + self._account["real_profit_today"] = 0.0 # 当日实现盈亏 = SUM(持仓当日实现盈亏) + self._account["profit_today"] = 0.0 # 当日盈亏 = 当日浮动盈亏 + 当日实现盈亏 + self._account["profit_rate_today"] = 0.0 # 当日盈亏比 = 当日盈亏 / (当前买入成本 if 当前买入成本 > 0 else 期初资产) + # 根据公式 账户权益 不需要计算 self._account["balance"] = static_balance + market_value + self._append_to_diffs(["accounts", "CNY"], self._account) + + def _check_insert_order(self, order, symbol, position, quote, underlying_quote=None): + # 无法计入 orderbook + if quote["ins_class"] != "STOCK": + order["last_msg"] = "不支持的合约类型,TqSimStock 只支持股票模拟交易" + order["status"] = "FINISHED" + + if order["status"] == "ALIVE" and not self._is_in_trading_time(quote): + order["last_msg"] = "下单失败, 不在可交易时间段内" + order["status"] = "FINISHED" + + if order["status"] == "ALIVE" and order["direction"] == "BUY": + price = _get_order_price(quote, order) + order["frozen_balance"] = price * order["volume_orign"] + order["frozen_fee"] = _get_stock_fee(order["direction"], order["volume_orign"], price) + if order["frozen_balance"] + order["frozen_fee"] > self._account["available"]: + order["frozen_balance"] = 0.0 + order["frozen_fee"] = 0.0 + order["last_msg"] = "开仓资金不足" + order["status"] = "FINISHED" + + if order["status"] == "ALIVE" and order["direction"] == "SELL": + if position["volume_his"] + position["shared_volume_today"] - position["sell_volume_today"] - position["sell_volume_frozen"] < order["volume_orign"]: + order["last_msg"] = "平仓手数不足" + order["status"] = "FINISHED" + + if order["status"] == "FINISHED": + self._append_to_diffs(["orders", order["order_id"]], order) + + def _on_insert_order(self, order, symbol, position, quote, underlying_quote=None): + """记录在 orderbook""" + if order["direction"] == "BUY": + self._adjust_account_by_order(buy_frozen_balance=order["frozen_balance"], buy_frozen_fee=order["frozen_fee"]) + self._append_to_diffs(["accounts", "CNY"], self._account) + else: + position["sell_volume_frozen"] += order["volume_orign"] + self._append_to_diffs(["positions", symbol], position) + + def _on_order_failed(self, symbol, order): + origin_frozen_balance = order["frozen_balance"] + origin_frozen_fee = order["frozen_fee"] + order["frozen_balance"] = 0.0 + order["frozen_fee"] = 0.0 + self._append_to_diffs(["orders", order["order_id"]], order) + # 调整账户和持仓 + if order["direction"] == "BUY": + self._adjust_account_by_order(buy_frozen_balance=-origin_frozen_balance, buy_frozen_fee=-origin_frozen_fee) + self._append_to_diffs(["accounts", "CNY"], self._account) + else: + position = self._positions[symbol] + position["sell_volume_frozen"] -= order["volume_orign"] + self._append_to_diffs(["positions", symbol], position) + + def _on_order_traded(self, order, trade, symbol, position, quote, underlying_quote): + origin_frozen_balance = order["frozen_balance"] + origin_frozen_fee = order["frozen_fee"] + order["frozen_balance"] = 0.0 + order["frozen_fee"] = 0.0 + order["volume_left"] = 0 + self._append_to_diffs(["trades", trade["trade_id"]], trade) + self._append_to_diffs(["orders", order["order_id"]], order) + + # 调整账户和持仓 + if order["direction"] == "BUY": + if position["volume"] == 0: + position["create_date"] = quote['datetime'][:10] + self._adjust_account_by_order(buy_frozen_balance=-origin_frozen_balance, buy_frozen_fee=-origin_frozen_fee) + # 修改 position 原始字段 + buy_balance = trade["volume"] * trade["price"] + position["buy_volume_today"] += trade["volume"] + position["buy_balance_today"] += buy_balance + position["buy_fee_today"] += trade["fee"] + # 修改 account 原始字段 + self._adjust_account_by_trade(buy_fee=trade["fee"], buy_balance=buy_balance) + self._adjust_position_account(position, pre_last_price=trade["price"], last_price=position["last_price"], + buy_volume=trade["volume"], buy_balance=buy_balance, buy_fee=trade["fee"]) + else: + position["sell_volume_frozen"] -= order["volume_orign"] + # 修改 position 原始字段 + sell_balance = trade["volume"] * trade["price"] + position["sell_volume_today"] += trade["volume"] + position["sell_balance_today"] += sell_balance + position["sell_fee_today"] += trade["fee"] + self._adjust_account_by_trade(sell_fee=trade["fee"], sell_balance=sell_balance) + self._adjust_position_account(position, last_price=quote["last_price"], sell_volume=trade["volume"], + sell_balance=sell_balance, sell_fee=trade["fee"]) + + self._append_to_diffs(["positions", symbol], position) + self._append_to_diffs(["accounts", "CNY"], self._account) + + def _on_update_quotes(self, symbol, position, quote, underlying_quote): + # 调整持仓保证金和盈亏 + if position["volume"] > 0: + if position["last_price"] != quote["last_price"]: + self._adjust_position_account(position, pre_last_price=position["last_price"], last_price=quote["last_price"]) + position["last_price"] = quote["last_price"] + # 修改辅助变量 + position["last_price"] = quote["last_price"] + self._append_to_diffs(["positions", symbol], position) # 一定要返回 position,下游会用到 future_margin 字段判断修改保证金是否成功 + self._append_to_diffs(["accounts", "CNY"], self._account) + + def _adjust_position_account(self, position, pre_last_price=float("nan"), last_price=float("nan"), buy_volume=0, buy_balance=0, buy_fee=0, sell_volume=0, sell_balance=0, sell_fee=0): + """ + 价格变化,使得 position 中的以下计算字段需要修改,这个函数计算出需要修改的差值部分,计算出差值部分修改 position、account + 有两种情况下调用 + 1. 委托单 FINISHED,且全部成交,分为4种:buy_open, buy_close, sell_open, sell_close + 2. 行情跳动 + """ + assert [buy_volume, sell_volume].count(0) >= 1 # 只有一个大于0, 或者都是0,表示价格变化导致的字段修改 + if buy_volume > 0: + position["volume"] += buy_volume + cost = buy_balance + buy_fee + market_value = buy_volume * position["last_price"] + position["buy_avg_price"] = (position["buy_balance_today"] + position["buy_fee_today"]) / position["buy_volume_today"] + buy_float_profit_today = (position["volume"] - (position["volume_his"] - position["sell_volume_today"])) * (last_price - position["buy_avg_price"]) # 今仓浮动盈亏 = (今持仓数量 - (昨持仓数量 - 今卖数量)) * (最新价 - 买入均价) + self._adjust_position(position, cost=cost, market_value=market_value, + sell_float_profit_today=0, + buy_float_profit_today=buy_float_profit_today, real_profit_today=0) + self._adjust_account_by_position(market_value=market_value, cost=cost, + float_profit_today=buy_float_profit_today, + real_profit_today=0) + elif sell_volume > 0: + position["volume"] -= sell_volume + cost = -sell_volume * (position["cost_his"] / position["volume_his"]) + market_value = -sell_volume * position["last_price"] + real_profit_today = (sell_volume / position["volume_his"]) * position["sell_float_profit_today"] + sell_float_profit_today = position["sell_float_profit_today"] - real_profit_today + self._adjust_position(position, cost=cost, market_value=market_value, + sell_float_profit_today=sell_float_profit_today, + buy_float_profit_today=0, real_profit_today=real_profit_today) + self._adjust_account_by_position(market_value=market_value, cost=cost, + float_profit_today=sell_float_profit_today, + real_profit_today=real_profit_today) + else: + market_value = position["volume"] * last_price - position["market_value"] + sell_float_profit_today = (position["volume_his"] - position["sell_volume_today"]) * (last_price - pre_last_price) # 昨仓浮动盈亏 = (昨持仓数量 - 今卖数量) * (最新价 - 昨收盘价) + buy_float_profit_today = (position["volume"] - (position["volume_his"] - position["sell_volume_today"])) * (last_price - position["buy_avg_price"]) # 今仓浮动盈亏 = (今持仓数量 - (昨持仓数量 - 今卖数量)) * (最新价 - 买入均价) + self._adjust_position(position, cost=0, market_value=market_value, sell_float_profit_today=sell_float_profit_today, buy_float_profit_today=buy_float_profit_today, real_profit_today=0) + self._adjust_account_by_position(market_value=market_value, cost=0, float_profit_today=sell_float_profit_today+buy_float_profit_today, real_profit_today=0) + + # -------- 对于 position 的计算字段修改分为两类: + # 1. 针对手数相关的修改,在下单、成交时会修改 + # 2. 针对盈亏、保证金、市值的修改,由于参考合约最新价,在成交、行情跳动时会修改 + def _adjust_position(self, position, cost=0, market_value=0, sell_float_profit_today=0, buy_float_profit_today=0, real_profit_today=0): + # 更新 position 计算字段,根据差值更新的字段 + position["sell_float_profit_today"] += sell_float_profit_today + position["buy_float_profit_today"] += buy_float_profit_today + + position["cost"] += cost # 当前成本 = 期初成本 + 今买金额 + 今买费用 - 今卖数量 × (期初买入成本 / 期初持仓数量) + position["market_value"] += market_value # 当前市值 = 持仓数量 × 行情最新价 + position["float_profit_today"] += sell_float_profit_today + buy_float_profit_today # 当日浮动盈亏 = (昨持仓数量 - 今卖数量) * (最新价 - 昨收盘价) + (今持仓数量 - (昨持仓数量 - 今卖数量)) * (最新价 - 买入均价) + position["real_profit_today"] += real_profit_today # 当日实现盈亏 = 今卖数量 * (最新价 - 昨收盘价) - 今卖费用 + 今派息金额 + position["profit_today"] += sell_float_profit_today + buy_float_profit_today + real_profit_today + position["hold_profit"] += (market_value - cost) + position["real_profit_total"] += real_profit_today # 累计实现盈亏 += 当日实现盈亏(成本) + position["profit_total"] += real_profit_today + (market_value - cost) # 总盈亏 = 累计实现盈亏 + 持仓盈亏 + # 当日收益率 = 当日盈亏 / ( 当前成本 if 当前成本 > 0 else 期初市值) + if position["cost"] > 0: + position["profit_rate_today"] = position["profit_today"] / position["cost"] + else: + position["profit_rate_today"] = position["profit_today"] / position["market_value_his"] if position["market_value_his"] > 0 else 0.0 + # 累计收益率 = 总盈亏 / (当前成本 if 当前成本 > 0 else 期初成本) + if position["cost"] > 0: + position["profit_rate_total"] = position["profit_total"] / position["cost"] + else: + position["profit_rate_total"] = position["profit_total"] / position["cost_his"] if position["cost_his"] > 0 else 0.0 + + + # -------- 对于 account 的修改分为以下三类 + def _adjust_account_by_trade(self, buy_fee=0, buy_balance=0, sell_fee=0, sell_balance=0): + """由成交引起的 account 原始字段变化,account 需要更新的计算字段""" + # account 原始字段 + self._account["buy_balance_today"] += buy_balance # 当日买入占用资金(不含费用) + self._account["buy_fee_today"] += buy_fee # 当日买入累计费用 + self._account["sell_balance_today"] += sell_balance # 当日卖出释放资金 + self._account["sell_fee_today"] += sell_fee # 当日卖出累计费用 + # account 计算字段 + self._account["available"] += (sell_balance - buy_fee - sell_fee - buy_balance) + self._account["asset"] += (sell_balance - buy_fee - sell_fee - buy_balance) + self._account["drawable"] = max(self._account["available_his"] + min(0, self._account["sell_balance_today"] - self._account["buy_balance_today"] - self._account["buy_fee_today"] - self._account["buy_frozen_balance"] - self._account["buy_frozen_fee"]), 0) + + def _adjust_account_by_position(self, market_value=0, cost=0, float_profit_today=0, real_profit_today=0): + """由 position 变化,account 需要更新的计算字段""" + # account 计算字段,持仓字段求和的字段 + self._account["market_value"] += market_value + self._account["cost"] += cost + self._account["float_profit_today"] += float_profit_today + self._account["real_profit_today"] += real_profit_today + # account 计算字段 + self._account["asset"] += market_value # 当前资产 = 当前市值 + 当前可用余额 + 委托冻结金额 + 委托冻结费用 + # 当前可取余额 = MAX( 期初余额 + MIN(0,当日卖出释放资金 - 当日买入占用资金 - 委托冻结金额) , 0) + self._account["drawable"] = max(self._account["available_his"] + min(0, self._account["sell_balance_today"] - self._account["buy_balance_today"] - self._account["buy_fee_today"] - self._account["buy_frozen_balance"] - self._account["buy_frozen_fee"]), 0) + self._account["hold_profit"] = self._account["market_value"] - self._account["cost"] # 当日持仓盈亏 = 当前市值 - 当前买入成本 + self._account["profit_today"] = self._account["float_profit_today"] + self._account["real_profit_today"] # 当日盈亏 = 当日浮动盈亏 + 当日实现盈亏 + # 当日盈亏比 = 当日盈亏 / (当前买入成本 if 当前买入成本 > 0 else 期初资产) + if self._account["cost"] > 0: + self._account["profit_rate_today"] = self._account["profit_today"] / self._account["cost"] + else: + self._account["profit_rate_today"] = self._account["profit_today"] / self._account["asset_his"] if self._account["asset_his"] > 0 else 0.0 + + def _adjust_account_by_order(self, buy_frozen_balance=0, buy_frozen_fee=0): + """由 order 变化,account 需要更新的计算字段""" + self._account["buy_frozen_balance"] += buy_frozen_balance + self._account["buy_frozen_fee"] += buy_frozen_fee + self._account["available"] -= (buy_frozen_balance + buy_frozen_fee) + self._account["drawable"] = max(self._account["available_his"] + min(0, self._account["sell_balance_today"] - self._account["buy_balance_today"] - self._account["buy_fee_today"] - self._account["buy_frozen_balance"] - self._account["buy_frozen_fee"]), 0) diff --git a/tqsdk/tradeable/sim/utils.py b/tqsdk/tradeable/sim/utils.py index 2a7d62fd..ab2cb6a9 100644 --- a/tqsdk/tradeable/sim/utils.py +++ b/tqsdk/tradeable/sim/utils.py @@ -2,6 +2,9 @@ # -*- coding: utf-8 -*- __author__ = 'mayanqiong' +from datetime import datetime + +from tqsdk.datetime import _get_trading_day_from_timestamp, _get_trade_timestamp TRADING_DAYS_OF_YEAR = 250 @@ -70,3 +73,36 @@ def _get_future_margin(quote={}): if quote.get("ins_class", "").endswith("OPTION"): return float('nan') return quote.get("user_margin", quote.get("margin", float('nan'))) + + +def _get_order_price(quote, order): + # order 预期成交价格 + if order["price_type"] in ["ANY", "BEST", "FIVELEVEL"]: + ask_price, bid_price = _get_price_range(quote) + return ask_price if order["direction"] == "BUY" else bid_price + else: + return order["limit_price"] + + +def _get_stock_fee(direction, volume, price): + # 费用(BUY) = 佣金; 费用(SELL) = 佣金 + 印花税 + balance = volume * price + return max(balance * 0.00025, 5.0) + (0 if direction == "BUY" else balance * 0.001) + + +def _get_dividend_ratio(quote): + # 获取合约下一个交易日的送股、分红信息 + timestamp = _get_trading_day_from_timestamp(_get_trade_timestamp(quote['datetime'], float('nan')) + 86400000000000) # 下一交易日 + stock_dividend = _get_dividend_ratio_by_dt(quote['stock_dividend_ratio'], timestamp=timestamp) + cash_dividend = _get_dividend_ratio_by_dt(quote['cash_dividend_ratio'], timestamp=timestamp) + return stock_dividend, cash_dividend + + +def _get_dividend_ratio_by_dt(dividend_list: list, timestamp: int) -> float: + # 从分红/送股列表中找到指定的数据返回 + # ['20181102,0.400000', '20200624,0.400000', '20210716,0.400000'] '20210716' + dt = datetime.fromtimestamp(timestamp / 1000000000).strftime('%Y%m%d') # 转为 str 格式 + for item in dividend_list: + if item[:8] == dt: + return float(item[9:]) + return 0.0 diff --git a/tqsdk/tradeable/tradeable.py b/tqsdk/tradeable/tradeable.py index 9db028ae..261eb6c1 100644 --- a/tqsdk/tradeable/tradeable.py +++ b/tqsdk/tradeable/tradeable.py @@ -3,30 +3,32 @@ __author__ = 'mayanqiong' +from abc import ABC, abstractmethod from tqsdk.baseModule import TqModule -from tqsdk.tradeable.interface import IFuture -class Tradeable(TqModule): +class Tradeable(ABC, TqModule): - def __init__(self, broker_id, account_id) -> None: - """这里的几项属性为每一种可交易的类都应该有的属性""" - if not isinstance(broker_id, str): - raise Exception("broker_id 参数类型应该是 str") - if not isinstance(account_id, str): - raise Exception("account_id 参数类型应该是 str") - self._broker_id = broker_id.strip() # 期货公司(用户登录 rsp_login 填的) / TqSim / TqSimStock - self._account_id = account_id.strip() # 期货账户 (用户登录 rsp_login 填的) / TQSIM(user-defined) - self._account_key = self._get_account_key() # 每个账户的唯一标识 + def __init__(self): + self._account_key = self._get_account_key() # 每个账户的唯一标识,在账户初始化时就确定下来,后续只读不写 + + def _get_account_key(self): + return str(id(self)) @property + @abstractmethod def _account_name(self): # 用于界面展示的用户信息 - return self._account_id + raise NotImplementedError - def _get_account_key(self): - return str(id(self)) + @property + def _account_info(self): + # 用于 web_helper 获取初始账户信息 + return { + "account_key": self._account_key, + "account_name": self._account_name + } def _is_self_trade_pack(self, pack): """是否是当前交易实例应该处理的交易包""" @@ -38,13 +40,3 @@ def _is_self_trade_pack(self, pack): pack.pop("account_key", None) return True return False - - def _get_baseinfo(self): - # 用于 web_helper 获取初始账户信息 - return { - "broker_id": self._broker_id, - "account_id": self._account_id, - "account_key": self._account_key, - "account_name": self._account_name, - "account_type": "FUTURE" if isinstance(self, IFuture) else "STOCK" - } diff --git a/tqsdk/web/index.html b/tqsdk/web/index.html index 46aba3fd..da4d1763 100644 --- a/tqsdk/web/index.html +++ b/tqsdk/web/index.html @@ -1,4 +1,4 @@ -tqsdk-python-web
\ No newline at end of file + }
\ No newline at end of file diff --git a/tqsdk/web/js/app.fc01ac28.js b/tqsdk/web/js/app.fc01ac28.js new file mode 100644 index 00000000..7bcbddfa --- /dev/null +++ b/tqsdk/web/js/app.fc01ac28.js @@ -0,0 +1 @@ +(function(t){function e(e){for(var a,s,r=e[0],d=e[1],c=e[2],h=0,l=[];h1&&void 0!==arguments[1]?arguments[1]:"YYYY-MM-DD";return o()(t/1e6).format(e)}},mounted:function(){var t=this,e=["KQ.m@SHFE.au","KQ.m@SHFE.ag","KQ.m@CFFEX.T","KQ.m@CFFEX.TF"];this.$tqsdk.subscribe_quote(e),this.$tqsdk.on("rtn_data",(function(){var n,a=Object(y["a"])(e);try{for(a.s();!(n=a.n()).done;){var i=n.value,o=t.$tqsdk.get_by_path(["quotes",i,"datetime"]);t.currentDateTime=o&&o>t.currentDateTime?o.slice(0,19):t.currentDateTime}}catch(s){a.e(s)}finally{a.f()}}))}},w=v,k=(n("2dac"),Object(m["a"])(w,b,g,!1,null,null,null)),x=k.exports,j={name:"tq-header-view",components:{TqLayoutArea:_,TqReplayCtrl:x},props:{height:Number},methods:{formatDt:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"YYYY-MM-DD HH:mm:ss";return o()(t/1e6).format(e)}}},C=j,S=(n("4709"),Object(m["a"])(C,d,c,!1,null,null,null)),q=S.exports,A=38,$={name:"tq-app",components:{TqHeaderView:q},data:function(){return{headerHeight:A}},methods:{},created:function(){}},T=$,M=(n("5c0b"),Object(m["a"])(T,s,r,!1,null,null,null)),N=M.exports,z=n("0977"),O=n("8c4f"),I=n("2f62");a["a"].use(I["a"]);var E=new I["a"].Store({state:{mode:"",broker_id:"",user_name:"",py_file_status:"",md_url_status:"",file_path:"",file_name:"",start_dt:"",end_dt:"",current_dt:"",accounts:{},account_keys:[]},mutations:{set_action:function(t,e){for(var n in e.accounts)t.account_keys.push(n);Object.assign(t,e)},set_py_file_status:function(t,e){t.py_file_status=e}},actions:{}}),D=E,F=n("8629"),H=n("f843"),B=n("8144"),Y=n("efc9"),R=n("2d48"),P=n("6def"),L=n("e79d"),G=n("90b2"),W=n("c5c1"),Q=n("dcbf"),Z=n("e3f5"),U=n("5250"),J=n("76ea"),V=n("b3e6"),X=n("8679"),K=n("fe66"),tt=n("6252"),et=n("9259"),nt=n("aa30"),at=n("e0b9");a["a"].prototype.$Loading=at["a"],a["a"].prototype.$Message=nt["a"],a["a"].prototype.$Modal=et["a"],a["a"].prototype.$Notice=tt["a"],a["a"].prototype.$Spin=K["a"],a["a"].component("Layout",X["a"]),a["a"].component("Row",V["a"]),a["a"].component("Col",J["a"]),a["a"].component("Icon",U["a"]),a["a"].component("Button",Z["a"]),a["a"].component("ButtonGroup",Q["a"]),a["a"].component("Dropdown",W["a"]),a["a"].component("DropdownMenu",G["a"]),a["a"].component("DropdownItem",L["a"]),a["a"].component("Badge",P["a"]),a["a"].component("Radio",R["a"]),a["a"].component("Checkbox",Y["a"]),a["a"].component("RadioGroup",B["a"]),a["a"].component("Tabs",H["a"]),a["a"].component("TabPane",F["a"]),a["a"].component("Spin",K["a"]);n("4de4"),n("f00c"),n("8ba4"),n("b680");a["a"].filter("toFixed",(function(t,e){var n=Number(t);return e=Number.isInteger(e)?e:2,Number.isFinite(n)?n.toFixed(e):t||"-"}));var it=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"tq-home"},[n("div",{staticClass:"layout-area top-container",style:{height:t.topHeight+"px"}},[n("tq-top-view",{attrs:{height:t.topHeight,width:t.$root.windowWidth},on:{onChangeOnlyMainBoard:t.onChangeOnlyMainBoard}})],1),n("div",{staticClass:"layout-area bottom-container",style:{height:t.bottomHeight+"px",top:t.bottomYOffset+4+"px",backgroundColor:"#FEFEFE",boxShadow:t.showBottomShadow?"0px -3px 4px -1px #AFAFAF":""}},[n("div",{staticClass:"bottom-area-handle",on:{mousedown:t.handleMousedown}}),n("tq-bottom-view",{attrs:{height:t.bottomHeight,width:t.$root.windowWidth}})],1)])},ot=[],st=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"tq-top-view"},[n("tq-layout-area",{staticClass:"left-view",attrs:{height:t.height,width:t.chartWidth}},[n("tq-layout-area",{attrs:{height:26,width:600}},[n("span",{staticClass:"title"},[t._v(t._s(t.title))]),n("button-group-durations",{attrs:{duration:t.duration},on:{onChangeDuration:t.onChangeDuration}}),t._v(" "),n("Checkbox",{attrs:{size:"small",border:""},model:{value:t.onlyMainBoard,callback:function(e){t.onlyMainBoard=e},expression:"onlyMainBoard"}},[t._v("全屏/还原")])],1),n("tq-layout-area",{attrs:{top:26}},[n("tq-chart-components",{ref:"tqChart",attrs:{instrumentId:t.instrumentId,duration:t.duration,height:t.height-26,width:t.chartWidth,theme:t.theme,mainType:t.mainType}})],1)],1),n("tq-layout-area",{staticClass:"right-view",attrs:{height:t.height,width:t.rightWidth,horizontalAlign:"right"}},["backtest"!==t.$store.state.mode?n("tq-layout-area",{attrs:{height:t.rightClosed?t.height:160,width:t.rightWidth}},["backtest"!==t.$store.state.mode?n("quote-info",{attrs:{symbol:t.instrumentId,height:t.rightClosed?t.height:160,width:t.rightWidth}}):t._e()],1):t._e(),n("tq-layout-area",{attrs:{top:"backtest"!==t.$store.state.mode?160:0,height:t.rightClosed?0:18,width:t.rightWidth,otherStyle:{backgroundColor:"lightgrey",color:"#333333",fontSize:"12px",fontWeight:"700",overflow:"hidden"}}},[t._v(" 订阅合约列表 ")]),n("tq-layout-area",{attrs:{verticalAlign:"bottom",height:t.rightClosed?0:t.height-("backtest"!==t.$store.state.mode?160:0)-18,width:t.rightWidth,otherStyle:{overflowY:"scroll"}}},[n("subscribed-list",{on:{onChange:t.onChangeSelectedSubscribed}})],1),"backtest"!==t.$store.state.mode?n("div",{staticClass:"right-resize-tool",on:{click:t.toggleRightClosed}},[t.rightClosed?n("Icon",{attrs:{type:"ios-arrow-back"}}):n("Icon",{attrs:{type:"ios-arrow-forward"}})],1):t._e()],1)],1)},rt=[],dt=(n("99af"),function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"tq-button-group-durations"},[n("RadioGroup",{attrs:{type:"button",size:"small"},model:{value:t.selectedPeriod,callback:function(e){t.selectedPeriod=e},expression:"selectedPeriod"}},t._l(t.periods,(function(t){return n("Radio",{key:t,attrs:{label:t}})})),1)],1)}),ct=[],ut=(n("4160"),n("b64b"),n("07ac"),n("159b"),{"分时":6e10,"1m":6e10,"5m":3e11,"15m":9e11,"30m":18e11,"1h":36e11,"1d":864e11}),ht={data:function(){return{periods:Object.keys(ut),selectedPeriod:"",selectedDuration:null}},props:{duration:Number},watch:{duration:function(){if(this.duration!==this.selectedDuration){var t=Object.values(ut).indexOf(this.duration);t>-1?(this.selectedPeriod=ut[t],this.selectedDuration=this.duration):(this.selectedPeriod="",this.selectedDuration=null)}},selectedPeriod:function(){document.querySelectorAll(".tq-button-group-durations input[type=radio]").forEach((function(t){return t.blur()})),this.selectedDuration=ut[this.selectedPeriod],this.selectedPeriod&&this.selectedDuration&&this.$emit("onChangeDuration",this.selectedDuration,this.selectedPeriod)}}},lt=ht,pt=(n("326a"),Object(m["a"])(lt,dt,ct,!1,null,null,null)),mt=pt.exports,ft=n("6029"),_t=(n("22fe"),"web_chart"),bt="web_chart_focus",gt=null,yt=null,vt={name:"tq-chart-components",data:function(){return{id:"TQ-CHART",action:"run"}},props:{instrumentId:{type:String,default:"TQ.m@SHFE.au"},duration:{type:Number,default:6e10},width:Number,height:Number,theme:{type:String,default:"light"},mainType:{type:String,default:"candle"}},render:function(t){var e={class:{"theme-light":"light"===this.theme,"theme-dark":"dark"===this.theme},attrs:{id:this.id}};return t("div",e)},watch:{instrumentId:function(t,e){yt.removeMarkAll(),this.$nextTick(this.updateTqChart)},duration:function(t,e){yt.removeMarkAll(),this.$nextTick(this.updateTqChart)},width:function(t,e){yt&&yt.resize({width:t,height:this.height})},height:function(t,e){yt&&yt.resize({width:this.width,height:t})},mainType:function(t,e){yt.removeMarkAll(),this.$nextTick(this.updateTqChart)}},mounted:function(){var t=this;gt=this.$tqsdk.get({name:"klines",symbol:this.instrumentId,duration:this.duration}),yt=new ft["a"](this.id),yt.on("showRangeChanged",(function(e){if(-1!==e.rightId&&-1!==e.leftId)if("close"===t.mainType){var n=864e11,a=1;t.$tqsdk.set_chart({chart_id:_t,symbol:t.instrumentId,duration:6e10,trading_day_start:n*(1-a),trading_day_count:n*a})}else"candle"===t.mainType&&t.$tqsdk.set_chart({chart_id:_t,symbol:t.instrumentId,duration:t.duration,left_kline_id:Math.max(e.leftId-3e3,0),view_width:8964})})),yt.init({symbol:this.instrumentId,duration:this.duration,height:this.height,width:this.width,mainType:this.mainType,mainSeries:gt}),this.$tqsdk.on("ready",(function(){var e=t.$tqsdk.getQuote(t.instrumentId);yt.setPriceDecs(e.price_decs)})),this.$tqsdk.on("rtn_data",(function(){if(t.updatePositionLine(),t.updateTrades(),t.updateTqSdkChartData(),t.updatePositionRect(),"run"===t.action&&"backtest"===t.$tqsdk.get_by_path(["action","mode"])){var e=a["a"].$tqsdk.get_by_path(["_tqsdk_backtest","current_dt"]);e&&(t.$tqsdk.set_chart({chart_id:bt,symbol:t.instrumentId,duration:t.duration,view_width:yt.bar.barNumbers,focus_datetime:e,focus_position:Math.floor(yt.bar.barNumbers/2)}),t.action="backtest")}var n=t.$tqsdk.get_by_path(["charts",bt]);if(n&&!n.more_data&&n.left_id&&n.right_id){var i=[n.left_id,n.right_id],o=i[0],s=i[1];yt.setRange(o,s),yt.addHighlightBar("movetoid",o+Math.floor(yt.bar.barNumbers/2)),t.$tqsdk.set_chart({chart_id:bt,symbol:"",duration:yt.duration,view_width:0})}else{var r=t.$tqsdk.get_by_path(["charts",_t]);if("close"===t.mainType&&r&&!r.more_data&&(gt=t.$tqsdk.get({name:"klines",symbol:t.instrumentId,duration:6e10}),gt&>.trading_day_end_id>-1&>.trading_day_start_id>-1&&yt.setRange(gt.trading_day_start_id,gt.trading_day_end_id)),r&&!r.more_data&&t.$tqsdk.is_changed(gt)&&yt.draw(),"backtest"==t.action){var d=t.$tqsdk.get_by_path(["_tqsdk_backtest","current_dt"]);d&&(d>t.$store.state.end_dt&&(d=t.$store.state.end_dt),yt.setMaxDatetime(d),yt.draw())}}})),this.$eventHub.$on("moveChartToDt",(function(t){t=6311232e11+Math.floor((t-6311232e11)/yt.duration)*yt.duration,this.$tqsdk.set_chart({chart_id:bt,symbol:yt.symbol,duration:yt.duration,view_width:yt.bar.barNumbers,focus_datetime:t,focus_position:Math.floor(yt.bar.barNumbers/2)})}))},methods:{updateTqChart:function(){if(this.$tqsdk.set_chart({chart_id:_t,symbol:this.instrumentId,duration:this.duration,view_width:8964}),"backtest"==this.action){var t=this.$tqsdk.get_by_path(["_tqsdk_backtest","current_dt"]);t&&(t>this.$store.state.end_dt&&(t=this.$store.state.end_dt),this.$tqsdk.set_chart({chart_id:bt,symbol:this.instrumentId,duration:this.duration,view_width:yt.bar.barNumbers,focus_datetime:t,focus_position:Math.floor(yt.bar.barNumbers/2)}))}if(gt=this.$tqsdk.get({name:"klines",symbol:this.instrumentId,duration:this.duration}),yt){var e=this.$tqsdk.getQuote(this.instrumentId);yt.setMainSeries(this.instrumentId,this.duration,gt,this.mainType,{price_decs:e.price_decs,price_ticks:e.price_ticks}),this.$nextTick((function(){this.updateTqSdkChartData(!0),this.updateTrades(!0),this.updatePositionRect(!0)}))}},updateTqSdkChartData:function(){var t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],e=this.$tqsdk.get_by_path(["draw_chart_datas",yt.symbol,yt.duration]);if(t||e&&e._epoch===this.$tqsdk.dm._epoch)for(var n in e)yt.addSeries(n,e[n])},updatePositionLine:function(t){var e=this.$store.state.account_id;if(e){var n=this.$tqsdk.getByPath(["trade",e,"positions",yt.symbol]);n&&n.volume_long>0?yt.addMark({id:"pos_long",type:"h-line",boardId:"main",yAlign:"right",y1:n.open_price_long,stroke:"red",text:"多仓".concat(n.volume_long,"手@").concat(n.open_price_long)}):yt.removeMark("pos_long"),n&&n.volume_short>0?yt.addMark({id:"pos_short",type:"h-line",boardId:"main",yAlign:"right",y1:n.open_price_short,stroke:"green",text:"空仓".concat(n.volume_short,"手@").concat(n.open_price_short)}):yt.removeMark("pos_short")}},updateTrades:function(t){var e=this.$store.state.account_id;if(e){var n=this.$tqsdk.getByPath(["trade",e,"trades"]);for(var a in n){var i=n[a];i.volume>0&&i.exchange_id+"."+i.instrument_id===yt.symbol&&yt.addTradeArrow(a,i)}}},updatePositionRect:function(t){var e=this.$tqsdk.get_by_path(["snapshots"]);for(var n in e){var a=e[n];t?a.positions&&a.positions.hasOwnProperty(yt.symbol)&&yt.addPositionRecord(n,a.positions[yt.symbol]):a._epoch===this.$tqsdk.dm._epoch&&a.positions&&a.positions.hasOwnProperty(yt.symbol)&&yt.addPositionRecord(n,a.positions[yt.symbol])}},setOnlyMainBoard:function(t){yt.setOnlyMainBoard(t)}}},wt=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"subscribed-list"},[t._l(t.subscribedSymbols,(function(e){return[t._l(t.subscribed[e],(function(a){return[n("Button",{attrs:{size:"small",long:""},on:{click:function(n){return t.onClick(e,a)}}},[t._v(" "+t._s(e)+" "+t._s(t.ParseDurationToString(a))+" ")])]}))]}))],2)},kt=[];n("caad"),n("2532"),n("a434"),n("ac1f"),n("1276"),n("53ca");function xt(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:2;return"number"===typeof t?t.toFixed(e):t}function jt(t){switch(t){case"BUY":return"买";case"SELL":return"卖";default:return t}}function Ct(t){switch(t){case"OPEN":return"开";case"CLOSE":return"平";case"CLOSETODAY":return"平今";default:return t}}function St(t){var e="";if(t&&t>=1e9){var n=o.a.duration(t/1e9,"seconds");n.years()>0&&(e+=n.years()+"Y"),n.months()>0&&(e+=n.months()+"M"),n.days()>0&&(e+=n.days()+"D"),n.hours()>0&&(e+=n.hours()+"h"),n.minutes()>0&&(e+=n.minutes()+"m"),n.seconds()>0&&(e+=n.seconds()+"s")}return e}var qt=[],At={},$t={data:function(){return{subscribedSymbols:qt,subscribed:At}},props:{},watch:{subscribedSymbols:function(t,e){}},mounted:function(){var t=this;this.$tqsdk.on("rtn_data",(function(){var e=t.$tqsdk.getByPath(["subscribed"]);if(e&&e._epoch===t.$tqsdk.dm._epoch){for(var n in e){var a=e[n];if(Array.isArray(a.symbol))for(var i in a.symbol)t.addSubscribed(a.symbol[i],a.dur_nano);else t.addSubscribed(a.symbol,a.dur_nano)}t.updateSubscribedSymbols()}}))},methods:{addSubscribed:function(t,e){At[t]||(At[t]=[]),e||(e=""),At[t].includes(e)||At[t].push(e)},updateSubscribedSymbols:function(){for(var t in At)-1===qt.indexOf(t)&&qt.push(t)},onClick:function(t,e){this.$emit("onChange",t,e)},ParseDurationToString:St}},Tt=$t,Mt=Object(m["a"])(Tt,wt,kt,!1,null,null,null),Nt=Mt.exports,zt=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"quote-info-container"},[n("Row",{attrs:{"class-name":"quote-items-title"}},[n("Col",{attrs:{span:t.largeSize?12:24}},[n("div",[t._v(t._s(t.quote.ins_name))])]),n("Col",{attrs:{span:t.largeSize?12:24}},[n("div",[t._v(t._s(t.quote.ins_id))])])],1),n("Row",[n("Col",{attrs:{span:t.largeSize?6:12}},[n("div",[t._v("卖一")])]),n("Col",{attrs:{span:t.largeSize?9:12}},[n("div",[t._v(t._s(t.formatter(t.quote.ask_price1)))])]),n("Col",{attrs:{span:t.largeSize?9:"0"}},[t._v(" "+t._s(t.quote.ask_volume1)+" ")])],1),n("div",{staticClass:"outer"},[n("div",{staticClass:"inner",style:{width:100*t.innerWidth+"%"}})]),n("Row",[n("Col",{attrs:{span:t.largeSize?6:12}},[t._v(" 买一 ")]),n("Col",{attrs:{span:t.largeSize?9:12}},[t._v(" "+t._s(t.formatter(t.quote.bid_price1)))]),n("Col",{attrs:{span:t.largeSize?9:"0"}},[t._v(" "+t._s(t.quote.bid_volume1)+" ")])],1),n("Row",{attrs:{"class-name":"quote-items "+(t.largeSize?"large-size":"")}},[n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 最新价 ")]),n("Col",{class:t.classOfColor,attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.formatter(t.quote.last_price))+" ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 昨结算 ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.formatter(t.quote.pre_settlement))+" ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 涨跌 ")]),n("Col",{class:t.classOfColor,attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.formatter(t.quote.change))+" ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 今开 ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.quote.open)+" ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 涨跌幅 ")]),n("Col",{class:t.classOfColor,attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t._f("toFixed")(t.quote.change_percent))+"% ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 最高 ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.formatter(t.quote.highest))+" ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 总手 ")]),n("Col",{class:t.classOfColor,attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.formatterVolume(t.quote.volume))+" ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" 最低 ")]),n("Col",{attrs:{span:t.largeSize?6:24}},[t._v(" "+t._s(t.formatter(t.quote.lowest))+" ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" 涨停 ")]),n("Col",{staticClass:"R",attrs:{span:t.largeSize?6:"0"}},[t._v(" "+t._s(t.formatter(t.quote.upper_limit))+" ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" 收盘 ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" "+t._s(t.formatter(t.quote.close))+" ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" 跌停 ")]),n("Col",{staticClass:"G",attrs:{span:t.largeSize?6:"0"}},[t._v(" "+t._s(t.formatter(t.quote.lower_limit))+" ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" 结算 ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" "+t._s(t.formatter(t.quote.settlement))+" ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" 持仓量 ")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" "+t._s(t.quote.open_interest))]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" 日增")]),n("Col",{attrs:{span:t.largeSize?6:"0"}},[t._v(" "+t._s(Number.isNaN(t.quote.open_interest-t.quote.pre_open_interest)?"-":t.quote.open_interest-t.quote.pre_open_interest))])],1)],1)},Ot=[],It={ins_name:"-",ins_id:"-",price_decs:2,last_price:"-",ask_price1:"-",ask_volume1:"-",bid_price1:"-",bid_volume1:"-",pre_settlement:"-",change:"-",change_percent:"-",open:"-",close:"-",highest:"-",volume:"-",lowest:"-",upper_limit:"-",lower_limit:"-",settlement:"-",open_interest:"-",pre_open_interest:"-"},Et={data:function(){return{quote:Object.assign({},It)}},props:{symbol:String,height:Number,width:Number},watch:{symbol:function(t,e){this.$tqsdk.subscribeQuote(this.symbol);var n=this.$tqsdk.getQuote(this.symbol);Dt(this.quote,n)}},computed:{classOfColor:function(){return this.quote["change"]>0?"R":this.quote["change"]<0?"G":""},innerWidth:function(){return this.quote.bid_volume1/(this.quote.bid_volume1+this.quote.ask_volume1)},largeSize:function(){return this.width>100}},mounted:function(){var t=this;this.$tqsdk.on("rtn_data",(function(){var e=t.$tqsdk.getQuote(t.symbol);Dt(t.quote,e)}))},methods:{formatter:function(t){return xt(t,this.quote.price_decs)},formatterVolume:function(t){return"number"!==typeof t?t:t>1e8?(t/1e8).toFixed(1)+"亿":t>1e4?(t/1e4).toFixed(1)+"万":t}}};function Dt(t,e){Object.keys(It).forEach((function(n,a,i){t[n]!==e[n]&&(t[n]=e[n])}))}var Ft=Et,Ht=(n("19da"),Object(m["a"])(Ft,zt,Ot,!1,null,null,null)),Bt=Ht.exports,Yt=function(){return document.addEventListener?function(t,e,n){t&&e&&n&&t.addEventListener(e,n,!1)}:function(t,e,n){t&&e&&n&&t.attachEvent("on"+e,n)}}(),Rt=function(){return document.removeEventListener?function(t,e,n){t&&e&&t.removeEventListener(e,n,!1)}:function(t,e,n){t&&e&&t.detachEvent("on"+e,n)}}(),Pt={name:"tq-top-view",components:{TqLayoutArea:_,TqChartComponents:vt,ButtonGroupDurations:mt,QuoteInfo:Bt,SubscribedList:Nt},props:{width:Number,height:Number},data:function(){var t=240,e=80;return{onlyMainBoard:!1,rightClosed:!1,rightOpenedWidth:t,rightClosedWidth:e,instrumentId:"",duration:6e10,theme:"light",mainType:"candle"}},computed:{title:function(){return"".concat(this.instrumentId," - ").concat(St(this.duration))},chartWidth:function(){var t=this.$root.windowWidth-(this.rightClosed?this.rightClosedWidth:this.rightOpenedWidth);return Math.max(t,400)},rightWidth:function(){return(this.rightClosed?this.rightClosedWidth:this.rightOpenedWidth)-4}},watch:{onlyMainBoard:function(){this.rightClosed=this.onlyMainBoard,this.$emit("onChangeOnlyMainBoard",this.onlyMainBoard),this.$refs.tqChart.setOnlyMainBoard(this.onlyMainBoard)}},methods:{toggleRightClosed:function(){this.rightClosed=!this.rightClosed},onChangeDuration:function(t,e){this.duration=t,this.mainType="分时"===e?"close":"candle"},onChangeSelectedSubscribed:function(t,e){this.instrumentId=t,this.duration=e||6e10,this.mainType="candle"}},created:function(){var t=this;this.$tqsdk.on("rtn_data",(function(){t.subscribed=t.$tqsdk.get_by_path(["subscribed"]),t.subscribed&&t.subscribed[0]&&""===t.instrumentId&&(t.subscribedIndex=0,t.instrumentId=Array.isArray(t.subscribed[0].symbol)?t.subscribed[0].symbol[0]:t.subscribed[0].symbol,t.duration=t.subscribed[0].dur_nano||6e10)}))}},Lt=Pt,Gt=(n("e002"),Object(m["a"])(Lt,st,rt,!1,null,null,null)),Wt=Gt.exports,Qt=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"tq-bottom-view"},["backtest"===t.$store.state.mode?[n("tq-layout-area",{attrs:{width:t.width/2,height:t.height}},[n("Tabs",{attrs:{animated:!1}},[n("TabPane",{attrs:{label:"回测指标"}},[n("backtest-info",{attrs:{width:t.width,height:t.height-27}})],1),n("TabPane",{attrs:{label:"成交记录"}},[n("trades-table",{attrs:{width:t.width,height:t.height-27}})],1)],1)],1),n("tq-layout-area",{attrs:{horizontalAlign:"right",width:t.width/2,height:t.height,otherStyle:{borderLeft:"2px solid #E0E0E0"}}},[n("backtest-chart")],1)]:"run"===t.$store.state.mode||"replay"===t.$store.state.mode?[n("tq-layout-area",{attrs:{height:26,width:t.width}},[n("account-info",{attrs:{height:26}})],1),n("tq-layout-area",{attrs:{height:t.height-26,top:26,width:t.width}},[n("Tabs",[n("TabPane",{attrs:{label:"持仓"}},[n("positions-table",{attrs:{width:t.width,height:t.height-53}})],1),n("TabPane",{attrs:{label:"成交记录"}},[n("trades-table",{attrs:{width:t.width,height:t.height-53}})],1),n("TabPane",{attrs:{label:"委托单记录"}},[n("orders-table",{attrs:{width:t.width,height:t.height-53}})],1),n("TabPane",{attrs:{label:"账户详情"}},[n("accounts-table",{attrs:{width:t.width,height:t.height-53}})],1)],1)],1)]:t._e()],2)},Zt=[],Ut=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("div",{staticClass:"reports trades-table",style:{height:t.height+"px"}},[n("table",[n("thead",[n("tr",t._l(t.columns,(function(e){return n("th",{attrs:{width:e.width?e.width+"px":""}},[t._v(t._s(e.name))])})),0)]),n("tbody")])])},Jt=[],Vt={data:function(){var t=this;function e(e,n){var a=t.$tqsdk.getByPath(["quotes",n,"price_decs"]);return xt(e,a||4)}return{tbodyRoot:null,columns:[{name:"投资者",width:"100",key:"account_name"},{name:"成交合约",key:"instrument_id",width:"100",cb:function(t){return t.exchange_id+"."+t.instrument_id}},{name:"方向",key:"direction",width:"60",cb:function(t){return jt(t.direction)}},{name:"开平",key:"offset",width:"60",cb:function(t){return Ct(t.offset)}},{name:"成交价格",width:"60",key:"price",cb:function(t){return e(t.price,t.exchange_id+"."+t.instrument_id)}},{name:"手数",width:"60",key:"volume"},{name:"成交时间",key:"trade_date_time",width:"140",cb:function(t){return o()(t.trade_date_time/1e6).format("YYYY-MM-DD HH:mm:ss")}},{name:"报单编号",key:"trade_id",width:"260"}]}},props:{height:Number},mounted:function(){this.tbodyNode=document.querySelector(".trades-table table tbody");var t=this,e=function(){var e,n=Object(y["a"])(t.$store.state.account_keys);try{var a=function(){i=e.value;var n=t.$tqsdk.getByPath(["trade",i,"trades"]);if(!n)return"continue";var a=t.$store.state.accounts[i],o=[];for(var s in n)n[s]._epoch===t.$tqsdk.dm._epoch&&(n[s].account_name=a.account_name,o.push(n[s]));o.sort((function(t,e){return t.trade_date_time-e.trade_date_time}));for(var r=function(e){var n=t.append_trade(o[e]);n.onclick=function(a){var i=t.tbodyNode.querySelectorAll("tr");i.forEach((function(t){return t.className=""})),n.className="selected",t.$eventHub.$emit("moveChartToDt",o[e].trade_date_time)}},d=0;d0&&void 0!==arguments[0]?arguments[0]:0,e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];e&&t<100?(t=Math.max(t,0),this.chartInstance.showLoading("default",{text:"数据已加载 "+t+"%",color:"#c23531",textColor:"#000",maskColor:"rgba(255, 255, 255, 0.8)",zlevel:0})):this.chartInstance.hideLoading()},update:function(t){this.chartInstance.setOption(t)},resize:function(){this.chartInstance.resize()},clean:function(){window.removeEventListener("resize",this.chartInstance.resize),this.chartInstance.clear()}},mounted:function(){this.init()},beforeDestroy:function(){this.clean()}},De={components:{EChart:Ee},data:function(){return{daily_yield:[],defaultOption:[["grid.top","16px"],["grid.left","10px"],["grid.bottom","0px"],["grid.right","16px"],["series[0]",{type:"line",encode:{x:"datetime",y:["balance"]},smooth:!1,showAllSymbol:!0,areaStyle:{}}],["yAxis.show",!0],["yAxis.type","value"],["yAxis.max","dataMax"],["yAxis.min","dataMin"],["yAxis.axisLabel",{show:!0,formatter:function(t,e){return t.toFixed(2)},color:"#333333"}],["yAxis.splitLine",{show:!0}],["xAxis.show",!0],["xAxis.type","time"],["xAxis.axisLabel",{show:!0,color:"#333333"}],["xAxis.splitLine",{show:!0}],["xAxis.boundaryGap",!1],["tooltip",{trigger:"axis",triggerOn:"mousemove|click"}]]}},props:{height:Number,width:Number},methods:{},mounted:function(){var t=this;this.$tqsdk.on("rtn_data",(function(){var e=t.$tqsdk.get_by_path(["snapshots"]),n=[];for(var a in e)a>t.$store.state.end_dt||n.push([Number.parseInt(a/1e6),e[a].accounts.CNY.balance]);t.$refs.echart.update({dataset:{source:n}})}))}},Fe=De,He=(n("b7de"),Object(m["a"])(Fe,Me,Ne,!1,null,null,null)),Be=He.exports,Ye={name:"tq-bottom-view",components:{TqLayoutArea:_,TradesTable:te,OrdersTable:se,PositionsTable:le,AccountsTable:ge,AccountInfo:je,BacktestInfo:Te,BacktestChart:Be},props:{height:Number,width:Number},data:function(){return{}},methods:{},created:function(){}},Re=Ye,Pe=(n("2424"),Object(m["a"])(Re,Qt,Zt,!1,null,null,null)),Le=Pe.exports,Ge=38,We=4,Qe=38,Ze={name:"tq-home",components:{TqTopView:Wt,TqBottomView:Le},data:function(){var t=Math.max(260,Math.round(.68*this.$root.windowHeight)),e=Ge+t+1;return{isMoving:!1,headerHeight:Ge,topHeight:t,topHeightMin:260,bottomYOffset:e,bottomYOffsetMin:2*Ge,bottomYOffsetMax:this.$root.windowHeight-Qe,bottomHeight:this.$root.windowHeight-e,showBottomShadow:!1}},methods:{onResize:function(){this.bottomYOffsetMax=this.$root.windowHeight-Qe,this.computeLayouts()},handleMousedown:function(t){this.isMoving=!0,Yt(document,"mousemove",this.handleMove),Yt(document,"mouseup",this.handleUp)},handleMove:function(t){var e=t.pageY;e>=this.bottomYOffsetMax||e<=this.bottomYOffsetMin||(this.bottomYOffset=e,this.computeLayouts())},handleUp:function(){this.isMoving=!1,Rt(document,"mousemove",this.handleMove),Rt(document,"mouseup",this.handleUp)},computeLayouts:function(){var t=this.$root.windowHeight-this.bottomYOffset;t<=Qe&&(t=Qe,this.bottomYOffset=this.$root.windowHeight-t),this.showBottomShadow=this.bottomYOffset