diff --git a/examples/vn_trader/run.py b/examples/vn_trader/run.py index bcc8366d..e55a1cef 100644 --- a/examples/vn_trader/run.py +++ b/examples/vn_trader/run.py @@ -33,15 +33,16 @@ from vnpy.gateway.bitmex import BitmexGateway # from vnpy.gateway.gateios import GateiosGateway from vnpy.gateway.bybit import BybitGateway -from vnpy.app.cta_strategy import CtaStrategyApp +# from vnpy.app.cta_strategy import CtaStrategyApp # from vnpy.app.csv_loader import CsvLoaderApp # from vnpy.app.algo_trading import AlgoTradingApp -from vnpy.app.cta_backtester import CtaBacktesterApp -from vnpy.app.data_recorder import DataRecorderApp +# from vnpy.app.cta_backtester import CtaBacktesterApp +# from vnpy.app.data_recorder import DataRecorderApp # from vnpy.app.risk_manager import RiskManagerApp -from vnpy.app.script_trader import ScriptTraderApp -from vnpy.app.rpc_service import RpcServiceApp -from vnpy.app.spread_trading import SpreadTradingApp +# from vnpy.app.script_trader import ScriptTraderApp +# from vnpy.app.rpc_service import RpcServiceApp +# from vnpy.app.spread_trading import SpreadTradingApp +from vnpy.app.portfolio_manager import PortfolioManagerApp def main(): @@ -81,15 +82,16 @@ def main(): # main_engine.add_gateway(GateiosGateway) main_engine.add_gateway(BybitGateway) - main_engine.add_app(CtaStrategyApp) - main_engine.add_app(CtaBacktesterApp) + # main_engine.add_app(CtaStrategyApp) + # main_engine.add_app(CtaBacktesterApp) # main_engine.add_app(CsvLoaderApp) # main_engine.add_app(AlgoTradingApp) - main_engine.add_app(DataRecorderApp) + # main_engine.add_app(DataRecorderApp) # main_engine.add_app(RiskManagerApp) # main_engine.add_app(ScriptTraderApp) # main_engine.add_app(RpcServiceApp) - main_engine.add_app(SpreadTradingApp) + # main_engine.add_app(SpreadTradingApp) + main_engine.add_app(PortfolioManagerApp) main_window = MainWindow(main_engine, event_engine) main_window.showMaximized() diff --git a/vnpy/app/portfolio_manager/__init__.py b/vnpy/app/portfolio_manager/__init__.py new file mode 100644 index 00000000..27405d91 --- /dev/null +++ b/vnpy/app/portfolio_manager/__init__.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from vnpy.trader.app import BaseApp + +from .engine import PortfolioEngine, APP_NAME + + +class PortfolioManagerApp(BaseApp): + """""" + app_name = APP_NAME + app_module = __module__ + app_path = Path(__file__).parent + display_name = "投资组合" + engine_class = PortfolioEngine + widget_name = "PortfolioManager" + icon_name = "portfolio.ico" diff --git a/vnpy/app/portfolio_manager/engine.py b/vnpy/app/portfolio_manager/engine.py new file mode 100644 index 00000000..f78f70de --- /dev/null +++ b/vnpy/app/portfolio_manager/engine.py @@ -0,0 +1,381 @@ +from datetime import datetime +from typing import Dict, List, Set +from collections import defaultdict +from copy import copy + +from vnpy.event import Event, EventEngine +from vnpy.trader.engine import BaseEngine, MainEngine +from vnpy.trader.event import ( + EVENT_TRADE, EVENT_ORDER, EVENT_TICK, EVENT_CONTRACT, EVENT_TIMER +) +from vnpy.trader.constant import Direction, Offset, OrderType +from vnpy.trader.object import ( + OrderRequest, CancelRequest, SubscribeRequest, + OrderData, TradeData, TickData, ContractData +) +from vnpy.trader.utility import load_json, save_json + + +APP_NAME = "PortfolioManager" + +EVENT_PORTFOLIO_UPDATE = "ePortfioUpdate" +EVENT_PORTFOLIO_ORDER = "ePortfioOrder" +EVENT_PORTFOLIO_TRADE = "ePortfioTrade" + + +class PortfolioEngine(BaseEngine): + """""" + setting_filename = "portfolio_manager_setting.json" + + def __init__(self, main_engine: MainEngine, event_engine: EventEngine): + """""" + super().__init__(main_engine, event_engine, APP_NAME) + + self.inited = False + + self.strategies: Dict[str, PortfolioStrategy] = {} + self.symbol_strategy_map: Dict[str, List] = defaultdict(list) + self.order_strategy_map: Dict[str, PortfolioStrategy] = {} + self.active_orders: Set[str] = set() + + self.register_event() + + def init_engine(self): + """""" + if self.inited: + return + self.inited = True + + self.load_setting() + + def load_setting(self): + """""" + setting: dict = load_json(self.setting_filename) + + for d in setting.values(): + self.add_strategy( + d["name"], + d["vt_symbol"], + d["size"], + d["net_pos"], + d["open_price"], + d["last_price"], + d["create_time"], + d["note_text"], + ) + + def save_setting(self): + """""" + setting: dict = {} + + for strategy in self.strategies.values(): + setting[strategy.name] = { + "name": strategy.name, + "vt_symbol": strategy.vt_symbol, + "size": strategy.size, + "net_pos": strategy.net_pos, + "open_price": strategy.open_price, + "last_price": strategy.last_price, + "create_time": strategy.create_time, + "note_text": strategy.note_text, + } + + save_json(self.setting_filename, setting) + + def register_event(self): + """""" + self.event_engine.register(EVENT_ORDER, self.process_order_event) + self.event_engine.register(EVENT_TRADE, self.process_trade_event) + self.event_engine.register(EVENT_TICK, self.process_tick_event) + self.event_engine.register(EVENT_CONTRACT, self.process_contract_event) + self.event_engine.register(EVENT_TIMER, self.process_timer_event) + + def process_timer_event(self, event: Event): + """""" + if self.inited: + self.save_setting() + + def process_contract_event(self, event: Event): + """""" + contract: ContractData = event.data + + if contract.vt_symbol in self.symbol_strategy_map: + self.subscribe_data(contract.vt_symbol) + + def process_order_event(self, event: Event): + """""" + order: OrderData = event.data + + if order.vt_orderid not in self.active_orders: + return + + if not order.is_active(): + self.active_orders.remove(order.vt_orderid) + + strategy: PortfolioStrategy = self.order_strategy_map[order.vt_orderid] + strategy_order = copy(order) + strategy_order.gateway_name = strategy.name + + event = Event(EVENT_PORTFOLIO_ORDER, strategy_order) + self.event_engine.put(event) + + def process_trade_event(self, event: Event): + """""" + trade: TradeData = event.data + + strategy: PortfolioStrategy = self.order_strategy_map.get( + trade.vt_orderid) + if strategy: + strategy.update_trade( + trade.direction, + trade.volume, + trade.price + ) + + self.put_strategy_event(strategy.name) + + strategy_trade = copy(trade) + strategy_trade.gateway_name = strategy.name + event = Event(EVENT_PORTFOLIO_TRADE, strategy_trade) + self.event_engine.put(event) + + self.save_setting() + + def process_tick_event(self, event: Event): + """""" + tick: TickData = event.data + + strategies: List = self.symbol_strategy_map[tick.vt_symbol] + for strategy in strategies: + strategy.update_price(tick.last_price) + + self.put_strategy_event(strategy.name) + + def add_strategy( + self, + name: str, + vt_symbol: str, + size: int = 0, + net_pos: int = 0, + open_price: float = 0, + last_price: float = 0, + create_time: str = "", + note_text: str = "" + ): + """""" + if name in self.strategies: + return False + + if not size: + contract = self.main_engine.get_contract(vt_symbol) + if not contract: + return False + size = contract.size + + strategy = PortfolioStrategy( + name, + vt_symbol, + size, + net_pos, + open_price, + last_price, + create_time, + note_text + ) + + self.strategies[strategy.name] = strategy + self.symbol_strategy_map[strategy.vt_symbol].append(strategy) + self.save_setting() + + self.subscribe_data(vt_symbol) + self.put_strategy_event(name) + + return True + + def remove_strategy(self, name: str): + """""" + if name not in self.strategies: + return False + + strategy = self.strategies.pop(name) + self.symbol_strategy_map[strategy.vt_symbol].remove(strategy) + + return True + + def subscribe_data(self, vt_symbol: str): + """""" + contract = self.main_engine.get_contract(vt_symbol) + if not contract: + return + + req = SubscribeRequest( + symbol=contract.symbol, + exchange=contract.exchange + ) + self.main_engine.subscribe(req, contract.gateway_name) + + def send_order( + self, + name: str, + price: float, + volume: int, + direction: Direction, + offset: Offset = Offset.NONE + ): + """""" + strategy = self.strategies[name] + vt_symbol = strategy.vt_symbol + + contract = self.main_engine.get_contract(vt_symbol) + if not contract: + return False + + req = OrderRequest( + symbol=contract.symbol, + exchange=contract.exchange, + direction=direction, + type=OrderType.LIMIT, + volume=volume, + price=price, + offset=offset + ) + vt_orderid = self.main_engine.send_order(req, contract.gateway_name) + + self.order_strategy_map[vt_orderid] = strategy + self.active_orders.add(vt_orderid) + + return True + + def cancel_order(self, vt_orderid: str): + """""" + if vt_orderid not in self.order_strategy_map: + return False + + order = self.main_engine.get_order(vt_orderid) + + req = CancelRequest( + orderid=order.orderid, + symbol=order.symbol, + exchange=order.exchange + ) + self.main_engine.cancel_order(req, order.gateway_name) + + return True + + def cancel_all(self, name: str): + """""" + for vt_orderid in self.active_orders: + strategy = self.order_strategy_map[vt_orderid] + if strategy.name == name: + self.cancel_order(vt_orderid) + + def put_strategy_event(self, name: str): + """""" + strategy = self.strategies[name] + event = Event(EVENT_PORTFOLIO_UPDATE, strategy) + self.event_engine.put(event) + + def stop(self): + """""" + self.save_setting() + + +class PortfolioStrategy: + """""" + + def __init__( + self, + name: str, + vt_symbol: str, + size: int, + net_pos: int, + open_price: float, + last_price: float, + create_time: str, + note_text: str + ): + """""" + self.name: str = name + self.vt_symbol: str = vt_symbol + self.size: int = size + + self.net_pos: int = net_pos + self.open_price: float = open_price + self.last_price: float = last_price + + self.pos_pnl: float = 0 + self.realized_pnl: float = 0 + + self.create_time: str = "" + if create_time: + self.create_time = create_time + else: + self.create_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + self.note_text: str = note_text + + self.calculate_pnl() + + def calculate_pnl(self): + """""" + self.pos_pnl = (self.last_price - self.open_price) * \ + self.net_pos * self.size + + def update_trade( + self, + trade_direction: Direction, + trade_volume: int, + trade_price: float + ): + """""" + old_cost = self.net_pos * self.open_price + + if trade_direction == Direction.LONG: + new_pos = self.net_pos + trade_volume + + # Open new long position + if self.net_pos >= 0: + new_cost = old_cost + trade_volume * trade_price + self.open_price = new_cost / new_pos + # Close short position + else: + close_volume = min(trade_volume, abs(self.net_pos)) + realized_pnl = (trade_price - self.open_price) * \ + close_volume * (-1) + self.realized_pnl += realized_pnl + + if new_pos > 0: + self.open_price = trade_price + + # Update net pos + self.net_pos = new_pos + + else: + new_pos = self.net_pos - trade_volume + + # Open new short position + if self.net_pos <= 0: + new_cost = old_cost - trade_volume * trade_price + self.open_price = new_cost / new_pos + # Close long position + else: + close_volume = min(trade_volume, abs(self.net_pos)) + realized_pnl = (trade_price - self.open_price) * close_volume + self.realized_pnl += realized_pnl + + if new_pos < 0: + self.open_price = trade_price + + # Update net pos + self.net_pos = new_pos + + self.calculate_pnl() + + def update_price(self, last_price: float): + """""" + self.last_price = last_price + self.calculate_pnl() + + def update_note(self, note_text: str): + """""" + self.note_text = note_text diff --git a/vnpy/app/portfolio_manager/ui/__init__.py b/vnpy/app/portfolio_manager/ui/__init__.py new file mode 100644 index 00000000..aed1a423 --- /dev/null +++ b/vnpy/app/portfolio_manager/ui/__init__.py @@ -0,0 +1 @@ +from .widget import PortfolioManager diff --git a/vnpy/app/portfolio_manager/ui/portfolio.ico b/vnpy/app/portfolio_manager/ui/portfolio.ico new file mode 100644 index 00000000..09f632d8 Binary files /dev/null and b/vnpy/app/portfolio_manager/ui/portfolio.ico differ diff --git a/vnpy/app/portfolio_manager/ui/widget.py b/vnpy/app/portfolio_manager/ui/widget.py new file mode 100644 index 00000000..77b70ae4 --- /dev/null +++ b/vnpy/app/portfolio_manager/ui/widget.py @@ -0,0 +1,393 @@ +from vnpy.event import EventEngine +from vnpy.trader.engine import MainEngine +from vnpy.trader.constant import Direction, Offset +from vnpy.trader.ui import QtWidgets, QtGui +from vnpy.trader.ui.widget import ( + BaseMonitor, BaseCell, + PnlCell, DirectionCell, EnumCell, +) + +from ..engine import ( + PortfolioEngine, + APP_NAME, + EVENT_PORTFOLIO_UPDATE, + EVENT_PORTFOLIO_TRADE, + EVENT_PORTFOLIO_ORDER +) + + +class PortfolioManager(QtWidgets.QWidget): + """""" + + def __init__(self, main_engine: MainEngine, event_engine: EventEngine): + """""" + super().__init__() + + self.main_engine = main_engine + self.event_engine = event_engine + + self.portfolio_engine = main_engine.get_engine(APP_NAME) + + self.init_ui() + + def init_ui(self): + """""" + self.setWindowTitle("投资组合") + + strategy_monitor = PortfolioStrategyMonitor( + self.main_engine, self.event_engine) + order_monitor = PortfolioOrderMonitor( + self.main_engine, self.event_engine) + trade_monitor = PortfolioTradeMonitor( + self.main_engine, self.event_engine) + + self.trading_widget = StrategyTradingWidget(self.portfolio_engine) + self.management_widget = StrategyManagementWidget( + self.portfolio_engine, + self.trading_widget, + strategy_monitor + ) + + vbox = QtWidgets.QVBoxLayout() + vbox.addWidget(self.management_widget) + vbox.addWidget(self.create_group("策略", strategy_monitor)) + vbox.addWidget(self.trading_widget) + vbox.addWidget(self.create_group("委托", order_monitor)) + vbox.addWidget(self.create_group("成交", trade_monitor)) + + self.setLayout(vbox) + + def show(self): + """""" + self.portfolio_engine.init_engine() + self.management_widget.update_combo() + + self.showMaximized() + + def create_group(self, title: str, widget: QtWidgets.QWidget): + """""" + group = QtWidgets.QGroupBox() + + vbox = QtWidgets.QVBoxLayout() + vbox.addWidget(widget) + + group.setLayout(vbox) + group.setTitle(title) + + return group + + +class PortfolioStrategyMonitor(BaseMonitor): + """ + Monitor for portfolio strategy. + """ + + event_type = EVENT_PORTFOLIO_UPDATE + data_key = "name" + sorting = False + + headers = { + "name": {"display": "策略名称", "cell": BaseCell, "update": False}, + "vt_symbol": {"display": "交易合约", "cell": BaseCell, "update": False}, + "size": {"display": "合约乘数", "cell": BaseCell, "update": False}, + "net_pos": {"display": "策略持仓", "cell": BaseCell, "update": True}, + "open_price": {"display": "持仓价格", "cell": BaseCell, "update": True}, + "last_price": {"display": "最新价格", "cell": BaseCell, "update": True}, + "pos_pnl": {"display": "持仓盈亏", "cell": PnlCell, "update": True}, + "realized_pnl": {"display": "平仓盈亏", "cell": PnlCell, "update": True}, + "create_time": {"display": "创建时间", "cell": BaseCell, "update": False}, + } + + def remove_strategy(self, name: str): + """""" + if name not in self.cells: + return + + row_cells = self.cells.pop(name) + row = self.row(row_cells["net_pos"]) + self.removeRow(row) + + +class PortfolioTradeMonitor(BaseMonitor): + """ + Monitor for trade data. + """ + + event_type = EVENT_PORTFOLIO_TRADE + data_key = "" + + headers = { + "gateway_name": {"display": "策略名称", "cell": BaseCell, "update": False}, + "tradeid": {"display": "成交号 ", "cell": BaseCell, "update": False}, + "orderid": {"display": "委托号", "cell": BaseCell, "update": False}, + "symbol": {"display": "代码", "cell": BaseCell, "update": False}, + "exchange": {"display": "交易所", "cell": EnumCell, "update": False}, + "direction": {"display": "方向", "cell": DirectionCell, "update": False}, + "offset": {"display": "开平", "cell": EnumCell, "update": False}, + "price": {"display": "价格", "cell": BaseCell, "update": False}, + "volume": {"display": "数量", "cell": BaseCell, "update": False}, + "time": {"display": "时间", "cell": BaseCell, "update": False}, + } + + +class PortfolioOrderMonitor(BaseMonitor): + """ + Monitor for order data. + """ + + event_type = EVENT_PORTFOLIO_ORDER + data_key = "vt_orderid" + sorting = True + + headers = { + "gateway_name": {"display": "策略名称", "cell": BaseCell, "update": False}, + "orderid": {"display": "委托号", "cell": BaseCell, "update": False}, + "symbol": {"display": "代码", "cell": BaseCell, "update": False}, + "exchange": {"display": "交易所", "cell": EnumCell, "update": False}, + "type": {"display": "类型", "cell": EnumCell, "update": False}, + "direction": {"display": "方向", "cell": DirectionCell, "update": False}, + "offset": {"display": "开平", "cell": EnumCell, "update": False}, + "price": {"display": "价格", "cell": BaseCell, "update": False}, + "volume": {"display": "总数量", "cell": BaseCell, "update": True}, + "traded": {"display": "已成交", "cell": BaseCell, "update": True}, + "status": {"display": "状态", "cell": EnumCell, "update": True}, + "time": {"display": "时间", "cell": BaseCell, "update": True}, + } + + def init_ui(self): + """ + Connect signal. + """ + super(PortfolioOrderMonitor, self).init_ui() + + self.setToolTip("双击单元格撤单") + self.itemDoubleClicked.connect(self.cancel_order) + + def cancel_order(self, cell): + """ + Cancel order if cell double clicked. + """ + order = cell.get_data() + req = order.create_cancel_request() + self.main_engine.cancel_order(req, order.gateway_name) + + +class StrategyTradingWidget(QtWidgets.QWidget): + """""" + + def __init__(self, portfolio_engine: PortfolioEngine): + """""" + super().__init__() + + self.portfolio_engine = portfolio_engine + self.init_ui() + self.update_combo() + + def init_ui(self): + """""" + self.name_combo = QtWidgets.QComboBox() + + self.direction_combo = QtWidgets.QComboBox() + self.direction_combo.addItems( + [Direction.LONG.value, Direction.SHORT.value]) + + self.offset_combo = QtWidgets.QComboBox() + self.offset_combo.addItems([offset.value for offset in Offset]) + + double_validator = QtGui.QDoubleValidator() + double_validator.setBottom(0) + + self.price_line = QtWidgets.QLineEdit() + self.price_line.setValidator(double_validator) + + self.volume_line = QtWidgets.QLineEdit() + self.volume_line.setValidator(double_validator) + + for w in [ + self.name_combo, + self.price_line, + self.volume_line, + self.direction_combo, + self.offset_combo + ]: + w.setFixedWidth(150) + + send_button = QtWidgets.QPushButton("委托") + send_button.clicked.connect(self.send_order) + send_button.setFixedWidth(70) + + cancel_button = QtWidgets.QPushButton("全撤") + cancel_button.clicked.connect(self.cancel_all) + cancel_button.setFixedWidth(70) + + hbox = QtWidgets.QHBoxLayout() + hbox.addWidget(QtWidgets.QLabel("策略名称")) + hbox.addWidget(self.name_combo) + hbox.addWidget(QtWidgets.QLabel("方向")) + hbox.addWidget(self.direction_combo) + hbox.addWidget(QtWidgets.QLabel("开平")) + hbox.addWidget(self.offset_combo) + hbox.addWidget(QtWidgets.QLabel("价格")) + hbox.addWidget(self.price_line) + hbox.addWidget(QtWidgets.QLabel("数量")) + hbox.addWidget(self.volume_line) + hbox.addWidget(send_button) + hbox.addWidget(cancel_button) + hbox.addStretch() + + self.setLayout(hbox) + + def send_order(self): + """""" + name = self.name_combo.currentText() + + price_text = self.price_line.text() + volume_text = self.volume_line.text() + + if not price_text or not volume_text: + return + + price = float(price_text) + volume = float(volume_text) + direction = Direction(self.direction_combo.currentText()) + offset = Offset(self.offset_combo.currentText()) + + self.portfolio_engine.send_order( + name, + price, + volume, + direction, + offset + ) + + def cancel_all(self): + """""" + name = self.name_combo.currentText() + self.portfolio_engine.cancel_all(name) + + def update_combo(self): + """""" + strategy_names = list(self.portfolio_engine.strategies.keys()) + + self.name_combo.clear() + self.name_combo.addItems(strategy_names) + + +class StrategyManagementWidget(QtWidgets.QWidget): + """""" + + def __init__( + self, + portfolio_engine: PortfolioEngine, + trading_widget: StrategyTradingWidget, + strategy_monitor: PortfolioStrategyMonitor + ): + """""" + super().__init__() + + self.portfolio_engine = portfolio_engine + self.trading_widget = trading_widget + self.strategy_monitor = strategy_monitor + + self.init_ui() + self.update_combo() + + def init_ui(self): + """""" + self.name_line = QtWidgets.QLineEdit() + self.symbol_line = QtWidgets.QLineEdit() + self.remove_combo = QtWidgets.QComboBox() + + for w in [ + self.name_line, + self.symbol_line, + self.remove_combo + ]: + w.setFixedWidth(150) + + add_button = QtWidgets.QPushButton("创建策略") + add_button.clicked.connect(self.add_strategy) + + remove_button = QtWidgets.QPushButton("移除策略") + remove_button.clicked.connect(self.remove_strategy) + + hbox = QtWidgets.QHBoxLayout() + hbox.addWidget(QtWidgets.QLabel("策略名称")) + hbox.addWidget(self.name_line) + hbox.addWidget(QtWidgets.QLabel("交易合约")) + hbox.addWidget(self.symbol_line) + hbox.addWidget(add_button) + hbox.addStretch() + hbox.addWidget(self.remove_combo) + hbox.addWidget(remove_button) + + self.setLayout(hbox) + + def add_strategy(self): + """""" + name = self.name_line.text() + vt_symbol = self.symbol_line.text() + + if not name or not vt_symbol: + QtWidgets.QMessageBox.information( + self, + "提示", + "请输入策略名称和交易合约", + QtWidgets.QMessageBox.Ok + ) + + result = self.portfolio_engine.add_strategy(name, vt_symbol) + + if result: + QtWidgets.QMessageBox.information( + self, + "提示", + "策略创建成功", + QtWidgets.QMessageBox.Ok + ) + + self.update_combo() + else: + QtWidgets.QMessageBox.warning( + self, + "提示", + "策略创建失败,存在重名或找不到合约", + QtWidgets.QMessageBox.Ok + ) + + def remove_strategy(self): + """""" + name = self.remove_combo.currentText() + + if not name: + return + + result = self.portfolio_engine.remove_strategy(name) + + if result: + QtWidgets.QMessageBox.information( + self, + "提示", + "策略移除成功", + QtWidgets.QMessageBox.Ok + ) + + self.update_combo() + + self.strategy_monitor.remove_strategy(name) + else: + QtWidgets.QMessageBox.warning( + self, + "提示", + "策略移除失败,不存在该策略", + QtWidgets.QMessageBox.Ok + ) + + def update_combo(self): + """""" + strategy_names = list(self.portfolio_engine.strategies.keys()) + + self.remove_combo.clear() + self.remove_combo.addItems(strategy_names) + + self.trading_widget.update_combo()