|
| 1 | +import datetime |
| 2 | +import logging |
| 3 | +import sys |
| 4 | +import time |
| 5 | + |
| 6 | +import pandas as pd |
| 7 | + |
| 8 | +from tigeropen.common.consts import BarPeriod, SecurityType, Market, Currency |
| 9 | +from tigeropen.common.util.contract_utils import stock_contract |
| 10 | +from tigeropen.common.util.order_utils import limit_order |
| 11 | +from tigeropen.quote.quote_client import QuoteClient |
| 12 | +from tigeropen.tiger_open_config import get_client_config |
| 13 | +from tigeropen.trade.trade_client import TradeClient |
| 14 | + |
| 15 | +client_logger = logging.getLogger('client') |
| 16 | +client_logger.setLevel(logging.WARNING) |
| 17 | +client_logger.addHandler(logging.StreamHandler(sys.stdout)) |
| 18 | +logger = logging.getLogger(__name__) |
| 19 | +logger.setLevel(logging.INFO) |
| 20 | +logger.addHandler(logging.StreamHandler(sys.stdout)) |
| 21 | + |
| 22 | +pd.set_option('display.max_columns', 500) |
| 23 | +pd.set_option('display.max_rows', 100) |
| 24 | +pd.set_option('display.width', 1000) |
| 25 | + |
| 26 | +# 纳斯达克100指数成分股 Components of Nasdaq-100. Up to 2021/12/20 |
| 27 | +UNIVERSE_NDX = ["AAPL", "ADBE", "ADI", "ADP", "ADSK", "AEP", "ALGN", "AMAT", "AMD", "AMGN", "AMZN", "ANSS", "ASML", |
| 28 | + "ATVI", "AVGO", "BIDU", "BIIB", "BKNG", "CDNS", "CDW", "CERN", "CHKP", "CHTR", "CMCSA", "COST", "CPRT", |
| 29 | + "CRWD", "CSCO", "CSX", "CTAS", "CTSH", "DLTR", "DOCU", "DXCM", "EA", "EBAY", "EXC", "FAST", "FB", |
| 30 | + "FISV", "FOX", "GILD", "GOOG", "HON", "IDXX", "ILMN", "INCY", "INTC", "INTU", "ISRG", |
| 31 | + "JD", "KDP", "KHC", "KLAC", "LRCX", "LULU", "MAR", "MCHP", "MDLZ", "MELI", "MNST", "MRNA", "MRVL", |
| 32 | + "MSFT", "MTCH", "MU", "NFLX", "NTES", "NVDA", "NXPI", "OKTA", "ORLY", "PAYX", "PCAR", "PDD", "PEP", |
| 33 | + "PTON", "PYPL", "QCOM", "REGN", "ROST", "SBUX", "SGEN", "SIRI", "SNPS", "SPLK", "SWKS", "TCOM", "TEAM", |
| 34 | + "TMUS", "TSLA", "TXN", "VRSK", "VRSN", "VRTX", "WBA", "WDAY", "XEL", "XLNX", "ZM"] |
| 35 | + |
| 36 | +# 恒生科技指数成分股. Up to 2021/12/20 |
| 37 | +UNIVERSE_HSTECH = ["00241", "00268", "00285", "00522", "00700", "00772", "00780", "00909", "00981", "00992", "01024", |
| 38 | + "01347", "01810", "01833", "02013", "02018", "02382", "02518", "03690", "03888", "06060", "06618", |
| 39 | + "06690", "09618", "09626", "09698", "09888", "09961", "09988", "09999"] |
| 40 | + |
| 41 | +# 持仓股票个数 |
| 42 | +HOLDING_NUM = 5 |
| 43 | +# 订单检查次数 |
| 44 | +ORDERS_CHECK_MAX_TIMES = 10 |
| 45 | +# 获取行情每次请求symbol个数 |
| 46 | +REQUEST_SIZE = 50 |
| 47 | +TARGET_QUANTITY = "target_quantity" |
| 48 | +PRE_CLOSE = "pre_close" |
| 49 | +LATEST_PRICE = "latest_price" |
| 50 | +MARKET_CAPITAL = "market_capital" |
| 51 | +SYMBOL = "symbol" |
| 52 | +WEIGHT = "weight" |
| 53 | +TIME = "time" |
| 54 | +CLOSE = "close" |
| 55 | +DATE = "date" |
| 56 | +LOT_SIZE = "lot_size" |
| 57 | + |
| 58 | +PRIVATE_KEY_PATH = "your private key path" |
| 59 | +TIGER_ID = "your tiger id" |
| 60 | +ACCOUNT = "your account" |
| 61 | +client_config = get_client_config(private_key_path=PRIVATE_KEY_PATH, tiger_id=TIGER_ID, account=ACCOUNT) |
| 62 | +quote_client = QuoteClient(client_config, logger=client_logger) |
| 63 | +trade_client = TradeClient(client_config, logger=client_logger) |
| 64 | + |
| 65 | + |
| 66 | +def request(symbols, method, **kwargs): |
| 67 | + """ |
| 68 | + :param symbols: |
| 69 | + :param method: |
| 70 | + :param kwargs: |
| 71 | + :return: |
| 72 | + """ |
| 73 | + symbols = list(symbols) |
| 74 | + result = pd.DataFrame() |
| 75 | + for i in range(0, len(symbols), REQUEST_SIZE): |
| 76 | + part = symbols[i:i + REQUEST_SIZE] |
| 77 | + quote = method(part, **kwargs) |
| 78 | + result = result.append(quote) |
| 79 | + # for rate limit |
| 80 | + time.sleep(0.5) |
| 81 | + return result |
| 82 | + |
| 83 | + |
| 84 | +def get_quote(symbols): |
| 85 | + quote = request(symbols, quote_client.get_stock_briefs) |
| 86 | + return quote.set_index(SYMBOL) |
| 87 | + |
| 88 | + |
| 89 | +def get_trade_meta(symbols): |
| 90 | + metas = request(symbols, quote_client.get_trade_metas) |
| 91 | + return metas.set_index(SYMBOL) |
| 92 | + |
| 93 | + |
| 94 | +def get_history(symbols, total=200, batch_size=50) -> pd.DataFrame: |
| 95 | + """ |
| 96 | +
|
| 97 | + :param symbols: |
| 98 | + :param total: |
| 99 | + :param batch_size: |
| 100 | + :return: |
| 101 | + time open high low close volume |
| 102 | + date symbol |
| 103 | + 2021-03-05 00:00:00-05:00 AAPL 1614920400000 120.9800 121.935 117.5700 121.42 153766601 |
| 104 | + ADBE 1614920400000 444.8800 444.950 423.7101 440.83 4614971 |
| 105 | + ADI 1614920400000 149.0000 149.620 143.3900 148.88 4040153 |
| 106 | + ADP 1614920400000 171.8300 179.000 171.5003 178.26 2535893 |
| 107 | + ADSK 1614920400000 270.3300 270.330 255.0200 267.39 1835526 |
| 108 | + ... ... ... ... ... ... ... |
| 109 | + 2021-12-16 00:00:00-05:00 WBA 1639630800000 48.5035 50.150 48.5000 49.26 5551852 |
| 110 | + WDAY 1639630800000 277.3300 278.365 269.2600 272.23 1206784 |
| 111 | + XEL 1639630800000 68.5800 69.570 68.3100 68.95 3774564 |
| 112 | + XLNX 1639630800000 217.3700 218.080 198.5100 199.78 4299386 |
| 113 | + ZM 1639630800000 183.7900 185.720 177.0000 182.40 4224447 |
| 114 | + """ |
| 115 | + end = int(datetime.datetime.today().timestamp() * 1000) |
| 116 | + history = pd.DataFrame() |
| 117 | + for i in range(0, total, batch_size): |
| 118 | + if i + batch_size <= total: |
| 119 | + limit = batch_size |
| 120 | + else: |
| 121 | + limit = i + batch_size - total |
| 122 | + logger.info(f'query history, end_time:{end}, limit:{limit}') |
| 123 | + part = request(symbols, quote_client.get_bars, period=BarPeriod.DAY, end_time=end, limit=limit) |
| 124 | + part[DATE] = pd.to_datetime(part[TIME], unit='ms').dt.tz_localize('UTC').dt.tz_convert('US/Eastern') |
| 125 | + end = min(part[TIME]) |
| 126 | + history = history.append(part) |
| 127 | + history.set_index([DATE, SYMBOL], inplace=True) |
| 128 | + history.sort_index(inplace=True) |
| 129 | + return history |
| 130 | + |
| 131 | + |
| 132 | +class Strategy: |
| 133 | + |
| 134 | + def __init__(self): |
| 135 | + self.market = Market.US |
| 136 | + self.currency = Currency.USD |
| 137 | + self.universe = UNIVERSE_NDX |
| 138 | + # self.market = Market.HK |
| 139 | + # self.currency = Currency.HKD |
| 140 | + # self.universe = UNIVERSE_HSTECH |
| 141 | + |
| 142 | + self.selected_symbols = list() |
| 143 | + # 计算动量的时间周期 |
| 144 | + self.momentum_period = 30 |
| 145 | + # 持仓股票个数 |
| 146 | + self.holding_num = HOLDING_NUM |
| 147 | + # 调仓后隔夜剩余流动性目标占比,剩余流动性占比越高,风控状态越安全,如果隔夜剩余流动性占比过低(比如小于5%), 则存在被强平的风险。 |
| 148 | + self.target_overnight_liquidation_ratio = 0.6 |
| 149 | + |
| 150 | + def screen_stocks(self): |
| 151 | + """screen stock by price momentum |
| 152 | + 按动量筛选股票. 选取周期内涨幅最高的若干股票 |
| 153 | + :return: |
| 154 | + """ |
| 155 | + history = get_history(self.universe) |
| 156 | + close_data = history[CLOSE].unstack() |
| 157 | + momentum = close_data.pct_change(periods=self.momentum_period).iloc[-1] |
| 158 | + self.selected_symbols = momentum.nlargest(self.holding_num).index.values.tolist() |
| 159 | + return self.selected_symbols |
| 160 | + |
| 161 | + def rebalance_portfolio(self): |
| 162 | + """ |
| 163 | + 调仓。先将本次选股未选中但在持仓中的股票进行平仓,然后将选中的股票按照等股数权重买入 |
| 164 | + :return: |
| 165 | + """ |
| 166 | + position_list = trade_client.get_positions(sec_type=SecurityType.STK, market=self.market) |
| 167 | + positions = dict() |
| 168 | + for pos in position_list: |
| 169 | + positions[pos.contract.symbol] = pos.quantity |
| 170 | + |
| 171 | + need_close_symbols = set(positions.keys()) - set(self.selected_symbols) |
| 172 | + |
| 173 | + # 如果不是美股,需要获取股票的每手股数,每次下单的股数只能使用每手股数的整数倍 |
| 174 | + lot_size = get_trade_meta(set(positions.keys()).union(self.selected_symbols))[LOT_SIZE] |
| 175 | + latest_price = get_quote(need_close_symbols)[LATEST_PRICE] |
| 176 | + orders = list() |
| 177 | + for symbol in need_close_symbols: |
| 178 | + contract = stock_contract(symbol, currency=self.currency.name) |
| 179 | + # 将下单股数处理为每手股数的整数倍 |
| 180 | + quantity = int(positions[symbol] // lot_size[symbol] * lot_size[symbol]) |
| 181 | + if quantity == 0: |
| 182 | + logger.warning(f'can not place order with this quantity, symbol:{symbol}, lot_size:{lot_size[symbol]},' |
| 183 | + f'quantity:{positions[symbol]}') |
| 184 | + continue |
| 185 | + limit_price = latest_price[symbol] |
| 186 | + order = limit_order(account=ACCOUNT, |
| 187 | + contract=contract, |
| 188 | + action='SELL' if quantity > 0 else 'BUY', |
| 189 | + quantity=abs(quantity), |
| 190 | + limit_price=limit_price) |
| 191 | + orders.append(order) |
| 192 | + self.execute_orders(orders) |
| 193 | + |
| 194 | + # 环球账户 global account |
| 195 | + # asset = trade_client.get_assets(account=ACCOUNT, segment=True)[0].segments['S'] |
| 196 | + # target_overnight_liquidation = asset.equity_with_loan * self.target_overnight_liquidation_ratio |
| 197 | + # adjust_value = asset.sma - target_overnight_liquidation |
| 198 | + |
| 199 | + # 综合/模拟账户 prime/paper account |
| 200 | + asset = trade_client.get_prime_assets(account=ACCOUNT).segments['S'] |
| 201 | + # 调仓后目标隔夜剩余流动性(隔夜剩余流动性 overnight_liquidation = 含贷款价值总权益 equity_with_loan - 隔夜保证金 overnight_margin) |
| 202 | + # 隔夜剩余流动性比例 = 隔夜剩余流动性 overnight_liquidation / 含贷款价值总权益 equity_with_loan |
| 203 | + target_overnight_liquidation = asset.equity_with_loan * self.target_overnight_liquidation_ratio |
| 204 | + # 如果流动性充足,需要买入的金额 |
| 205 | + adjust_value = asset.overnight_liquidation - target_overnight_liquidation |
| 206 | + if adjust_value <= 0: |
| 207 | + logger.info('no enough liquidation') |
| 208 | + return |
| 209 | + quote = get_quote(self.selected_symbols) |
| 210 | + # 按持股数量等权重持仓,equal weight |
| 211 | + quote[WEIGHT] = 1 / len(self.selected_symbols) |
| 212 | + quote[TARGET_QUANTITY] = (adjust_value * quote[WEIGHT] / quote[LATEST_PRICE]).astype(int) |
| 213 | + |
| 214 | + orders = list() |
| 215 | + for symbol in quote.index: |
| 216 | + contract = stock_contract(symbol, self.currency.name) |
| 217 | + quantity = int(quote[TARGET_QUANTITY][symbol] // lot_size[symbol] * lot_size[symbol]) |
| 218 | + # 该检查主要针对非美股。如果目前下单股数不是log_size的整数倍,则不能下单,只能通过app进行碎股卖出 |
| 219 | + if quantity == 0: |
| 220 | + logger.warning(f'can not place order with this quantity, symbol:{symbol}, lot_size:{lot_size[symbol]},' |
| 221 | + f'quantity:{quote[TARGET_QUANTITY][symbol]}') |
| 222 | + continue |
| 223 | + order = limit_order(account=ACCOUNT, |
| 224 | + contract=contract, |
| 225 | + action='BUY', |
| 226 | + quantity=quantity, |
| 227 | + limit_price=quote[LATEST_PRICE][symbol]) |
| 228 | + order.time_in_force = 'GTC' # 'DAY' 日内有效 / 'GTC' 撤销前有效 |
| 229 | + orders.append(order) |
| 230 | + self.execute_orders(orders) |
| 231 | + |
| 232 | + def execute_orders(self, orders): |
| 233 | + local_orders = dict() |
| 234 | + for order in orders: |
| 235 | + try: |
| 236 | + trade_client.place_order(order) |
| 237 | + logger.info(f'place order, {order.action} {order.contract.symbol} {order.quantity} {order.limit_price}') |
| 238 | + local_orders[order.id] = order |
| 239 | + except Exception as e: |
| 240 | + logger.error(f'place order error:{order}') |
| 241 | + logger.error(e, exc_info=True) |
| 242 | + |
| 243 | + time.sleep(20) |
| 244 | + i = 0 |
| 245 | + while i <= ORDERS_CHECK_MAX_TIMES: |
| 246 | + logger.info(f'check {i} times') |
| 247 | + history_open_orders = trade_client.get_open_orders(account=ACCOUNT, sec_type=SecurityType.STK, |
| 248 | + market=self.market, |
| 249 | + start_time=self.get_time_from_now( |
| 250 | + datetime.timedelta(days=1)), |
| 251 | + end_time=self.get_time_from_now()) |
| 252 | + if not history_open_orders: |
| 253 | + break |
| 254 | + |
| 255 | + # 检查一定次数后如果还未成交, 进行一次改单, 修改限价为最新价格 |
| 256 | + if i == ORDERS_CHECK_MAX_TIMES // 2: |
| 257 | + for open_order in history_open_orders: |
| 258 | + latest_price = get_quote([open_order.contract.symbol])[LATEST_PRICE][open_order.contract.symbol] |
| 259 | + try: |
| 260 | + trade_client.modify_order(open_order, limit_price=latest_price) |
| 261 | + logger.info(f'modify order, id:{open_order.id}, symbol:{open_order.contract.symbol},' |
| 262 | + f' old_price:{open_order.limit_price}, new_price:{latest_price}') |
| 263 | + except Exception as e: |
| 264 | + logger.error(f'modify order error:{open_order.id}') |
| 265 | + logger.error(e) |
| 266 | + # 如果达到最大检查次数还未成交,则进行撤单 |
| 267 | + if i >= ORDERS_CHECK_MAX_TIMES: |
| 268 | + for order in history_open_orders: |
| 269 | + logger.info(f'the order was not filled, now cancel it: {order}') |
| 270 | + try: |
| 271 | + trade_client.cancel_order(ACCOUNT, id=order.id) |
| 272 | + except Exception as e: |
| 273 | + logger.error(f'cancel order error: {order}') |
| 274 | + logger.error(e, exc_info=True) |
| 275 | + i += 1 |
| 276 | + time.sleep(10) |
| 277 | + |
| 278 | + # 打印已成交订单信息 |
| 279 | + filled_orders = trade_client.get_filled_orders(account=ACCOUNT, |
| 280 | + sec_type=SecurityType.STK, |
| 281 | + market=self.market, |
| 282 | + start_time=self.get_time_from_now(datetime.timedelta(days=1)), |
| 283 | + end_time=self.get_time_from_now()) |
| 284 | + order_infos = [(str(order.id) + ':' + order.contract.symbol + ':' + order.action + ':' + str(order.filled) |
| 285 | + + ':' + str(order.avg_fill_price)) for order in filled_orders] |
| 286 | + logger.info(f'recently filled orders:{order_infos}') |
| 287 | + |
| 288 | + # 打印未成交订单信息 |
| 289 | + unfilled_order_ids = set(local_orders.keys()) - set(order.id for order in filled_orders) |
| 290 | + for order_id in unfilled_order_ids: |
| 291 | + order = trade_client.get_order(ACCOUNT, id=order_id) |
| 292 | + logger.info(f'order was cancelled, id:{order.id}, status:{order.status}, reason:{order.reason}') |
| 293 | + |
| 294 | + @staticmethod |
| 295 | + def get_time_from_now(delta=None): |
| 296 | + if not delta: |
| 297 | + return int(datetime.datetime.now().timestamp()) * 1000 |
| 298 | + return int((datetime.datetime.now() - delta).timestamp()) * 1000 |
| 299 | + |
| 300 | + def run(self): |
| 301 | + perms = quote_client.grab_quote_permission() |
| 302 | + logger.info(perms) |
| 303 | + self.screen_stocks() |
| 304 | + self.rebalance_portfolio() |
| 305 | + |
| 306 | + |
| 307 | +if __name__ == '__main__': |
| 308 | + strategy = Strategy() |
| 309 | + strategy.run() |
0 commit comments