diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..939468f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env.icli + +dist/ +cache*/ +icli/hist/ + +*.log +*.json +*.txt +poetry.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c15087f --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2021 Matt Stancliff + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..60c4c72 --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +icli: IBKR live trade cli +========================= + +`icli` is a command line interface for live trading (or sandbox/paper trading) using IBKR accounts. + +The intended use case is for scalping or swing trading, so we prioritize easy of understanding your positions and active orders while removing unnecessary steps when placing orders. + +## Demo Replay + +Watch the console replay demo below to see a paper trading account where we add some live quotes for stocks and options, remove quotes, place orders to buy and sell, check execution details to view aggregate commission charges, check outstanding order details, and add some live futures quotes too. + +[![asciicast](https://asciinema.org/a/424814.svg)](https://asciinema.org/a/424814) + +## Features + +- allows full trading and data access to your IBKR account using only a CLI + - note: IBKR doesn't allow all operations from their API, so some operations like money transfers, funding, requesting market data, requesting trading permissions, etc, still need to use the mobile/web apps. +- allows trading as fast as you can type (no need to navigate multiple screens / checks / pre-flight confirmations) + - in fact, no confirmation for anything. you type it, it happens. + - forward implication: also allows losing money as fast as you can type +- commands can be entered as any unambiguous prefix + - e.g. `position` command can be entered as just `p` because it doesn't conflict with any other command + - but for `qquote` and `quote` commands, so those must be clarified by using `qq` or `qu` at a minimum +- interface shows balances, components of account value, P&L, and other account stats updating in real time +- an excessive amount of attention is given to formatting data for maximum readability and quick understanding at a glance + - due to density of visible data, it's recommended to run a smaller terminal font size to enable 170+ character wide viewing +- uses console ansi color gradients to show the "goodness" or "badness" of current price quotes + - bad is red + - more bad is more red + - good is green + - more good is more green + - really really good is blue (>= 0.98% change) +- helpful CLI prompts for multi-stage operations (thanks to [questionary](https://github.com/tmbo/questionary)) +- selects correct class of IBKR contract based on names entered (see: `helpers.py:contractForName()`) + - futures are prefixed with slashes, as is a norm: `/ES`, `/MES`, `/NQ`, `/MNQ`, etc + - options are the full OCC symbol (no spaces): `AAPL210716C00155000` + - future options start with a slash: `/ES210716C05000000` + - warrants, bonds, bills, forex, etc, aren't currently addressable in the CLI because we didn't decide on a naming convention yet + - spreads can be entered as a full buy/sell ratio description like: + - `"bto 1 AAPL210716C00155000 sto 2 AAPL210716C00160000 bto 1 AAPL210716C00165000"` + - works for adding live quotes (`add "bto ... sto ..."`) and for requesting spread trades using the `spread` command + - for ordering spreads, you specify the baseline spread per-leg ratio (e.g. butterflies are 1:2:1 as above), then the total spread order is the quantity requested multiplied by each leg ratio (e.g. butterfly 1:2:1 with quantity 100 will order 100 + 200 + 100 = 400 total contracts via a [COB](https://flextrade.com/simplifying-complexity-trading-complex-order-books-in-options-part-1/)) +- the positions CLI view also shows matched closing orders for each currently owned symbol +- helper shorthands, like an `EVICT [symbol] [quantity]` command to immediately yeet an entire equity position into a [MidPrice](https://www.interactivebrokers.com/en/index.php?f=36735) order with no extra steps + - for closing any position, you can enter `-1` as the quantity to use full quantity held +- support for showing depth-of-market requests for a quick glance at market composition (`dom` command) + - though, IBKR DOM is awful because even if you do pay the extra $300+/month to get full access, it only returns 5 levels on each side. If you want DOM, I'd recommend WeBull's TotalView discount package for $1.99/month and for options use futu/moomoo "Option Order Book Depth" quote pack (which includes real time options quotes) for $3.99/month (though, futu/momo is still pseudo-dom because it just shows top-of-book for each exchange instead of actual depth at any exchange). +- easy order modifications/updates + - though, IBKR recommends you only modify order quantity and price, otherwise the order should be canceled and placed again if other fields need to be adjusted. +- real time notification of order executions + - also plays [the best song ever](https://www.youtube.com/watch?v=VwEnx_NMZ4I&t=5s) when any trade is successfully executed as buy or sell +- quick order cancellation—need to bail on one or more orders ASAP before they try to execute? we got u fam + - `cancel` command with no arguments will bring up a menu for you to select one or more live orders to immediately cancel. + - `cancel order1 order2 ... orderN` command with arguments will also cancel each requested order id immediately. + - order ids are viewable in the `orders` command output table +- quick helper to add all waiting orders to your live real time quote view (`oadd`: add all order symbols to quote view) +- you can add individual symbols to the live quote view (equity, option, future, even spreads) (`add [symbols...]`: add symbols to quote view, quoted symbols can be spreads like `add AAPL QQQ "buy 1 AAPL210716C00160000 sell 1 AAPL210716C00155000"` to quote a credit spread and two equity symbols) +- you can also check quotes without adding them to the persistent live updating quote view with `qquote [symbols...]` + - the individual quote request process is annoyingly slow because of how IBKR reports quotes, but it still works +- view your trade execution history for the current day (`executions` command) + - includes per-execution commission cost (or credit!) + - also shows the sum total of your commissions for the day + +### Streaming Quote View + +#### Stocks / Futures + +```haskell +ES : 4,360.25 ( 1.55% 67.00) ( 1.09% 47.25) 4,362.00 4,293.25 4,360.00 x 158 4,360.25 x 112 4,310.25 4,313.00 +``` + +Live stock / etf / etn / futures / etc quotes show: + +- quote symbol (`ES`) +- current estimated price (`4,360.25`) +- low: percentage and point amount above low for the session (`( 1.55% 67.00)`) + - always positive unless symbol is trading at its low price of the day +- close: percentage and point amount from the previous session close (`( 1.09% 47.25)`) + - the typical "daily price change" value +- session high (`4,362.00`) +- session low (`4,293.25`) +- NBBO bid price x size (`4,360.00 x 158`) +- NBBO ask price x size (`4,360.25 x 112`) +- session open price (`4,310.25`) +- previous session close price (`4,313.00`) + +For more detailed level 2 quotes, we'd recommend [WeBull's TotalView integration](https://www.webull.com/activity/get-free-nasdaq-totalview) because they have a special deal with NASDAQ to allow TotalView for $1.99/month instead of paying IBKR hundreds in fees per month to get limited depth views. + +#### Options + +```haskell +TSLA 210716C00700000: [u 653.98 ( -7.04%)] [iv 0.47] 3.37 ( -32.57% -1.65 5.00) ( 14.29% 0.40 2.95) ( -24.24% -1.10 4.45) 3.35 x 4 3.40 x 3 +``` + +Live option quotes display more details because prices move faster: + +- quote symbol (OCC symbol *with* spaces) (`TSLA 210716C00700000`) +- live underlying quote price (`653.98`) +- percentage difference between live underlying price and strike price (`-7.04%`) + - with current underlying price `653.98` and strike at `$700.00`, the stock is currently 7.04% under ATM for the strike: + - `653.98 * 1.0704 = 700.020192` +- iv for contract based on [last traded price as calculated by IBKR](https://interactivebrokers.github.io/tws-api/option_computations.html) (`[iv 0.47]`) +- current estimated price (`3.37`) +- high: percentage, point difference, and traded high for the session (`( -32.57% -1.65 5.00)`) + - current option price of `3.37` is `-32.57%` (`-1.65` points down) from high of day, which was `5.00` + - will always be negative (or 0% if trading at high of day) +- low: percentage, point difference, and traded low for the session (`( 14.29% 0.40 2.95)`) + - will always be positive (or 0% if trading at low of day) +- close: percentage, point difference, and last traded price from previous session (`( -24.24% -1.10 4.45)`) +- NBBO bid price x size (`3.35 x 4`) +- NBBO ask price x size (`3.40 x 3`) + +For more detailed option level 2 quotes, we'd recommend [futu/moomoo](https://www.moomoo.com/download/appStore) in-app real time OPRA Level 2 quotes for $3.99/month. They also have a built-in unusual options volume scanner, and they now have [a trading and quote API](https://openapi.moomoo.com/futu-api-doc/en/), but [API quotes are billed differently than in-app quotes](https://qtcard.moomoo.com/buy?market_id=2&qtcard_channel=2&good_type=1024#/) and the API docs are [poorly translated into english](https://openapi.moomoo.com/futu-api-doc/en/qa/opend.html#3043), so ymmv. + +futu/moomoo also lets you buy equity depth for ARCA/OpenBook/CBOE/BZX/NASDAQ exchanges, but they charge regular market prices for each of those, so you'd be paying over $100/month for full depth coverage (and their TotalView alone is $25.99/month while WeBull offers it for $1.99/month). + +#### Quote Order + +The quote view uses this order for showing quotes based on security type: + +- futures +- stocks / etf / etn / warrants / etc +- single future options +- single options +- option spreads + +Futures are sorted first by our specific futures order defined by the `FUT_ORD` dict in `cli.py` then by name if there isn't a specific sort order requested (because we want `/ES` `/NQ` `/YM` first in the futures list and not `/GBP` `/BTC` etc). + +Stocks/etfs sort in REVERSE alphabetical order because it's easier to see the entry point of the stocks view at the lowest point rather than visually tracking the break where futures and stocks meet. + +Single option quotes are displayed in alphabetical order. + +Finally, option spreads show last because they are multi-line displays showing each symbol leg per spread. + +The overall sort is controlled via `cli.py:sortQuotes()` + +## How to Login + +IBKR only exposes their trade API via a gateway application (Gateway or TWS) which proxies requests between your API consumer applications and the IBKR upstream API itself. + +First download the IBKR Gateway, login to the gateway (which will manage the connection attempts to IBKR trade and data services), then have your CLI connect to the gateway. + +### Download Gateway + +- Download the [IBKR Gateway](https://www.interactivebrokers.com/en/index.php?f=16457) (you can also use TWS as an API gateway, but TWS wastes extra resources if you only need API access and also crashes if you have certain OS enhancements running) +- Login to the gateway with your IBKR username and password (will require 2FA to your phone using the IBKR app) + - The IBKR gateway will disconnect a minimum of twice per day: + - IBKR gateway insists on restarting itself once per day, but you can modify the daily restart time. + - this restart is a hard restart where your CLI application will lose connection to the IBKR gateway itself since the gateway process exits and restarts (note: IBKR Gateway caches your login credentials for up to a week, so you usually don't have to re-login or 2fa when it auto-restarts nightly) + - The gateway will also have a 30 second to 5 minute upstream network disconnect once per night when the remote IBKR systems do a nightly reboot on their own. During this time your CLI application will remain connected to the gateway, but won't be receiving any updates and can't send any new orders or requests. Also this downtime happens while futures markets are open, but you won't be able to access markets during the nightly reboot downtime, so make sure your risk is managed via brackets or stops. + - There is no reliable way to run the IBKR Gateway completely unattended for multiple weeks due to the manual 2FA and username/password re-entry process. + +### Download `icli` + +Download `icli` as a new repo: + +```bash +git clone https://github.com/mattsta/icli +``` + +Create your local environment: + +```bash +poetry update +``` + +Even though you are logged in to the gateway, the IBKR API still requires your account ID for some actions (because IBKR allows multiple account management, so even if you are logged in as you, it needs to know which account you _really_ want to modify). + +- Configure your IBKR account id as environment variable or in `.env.icli` as: + - `ICLI_IBKR_ACCOUNT_ID="U..."` + +- You can also configure the gateway host and port to connect to using: + - `ICLI_IBKR_HOST="127.0.0.1"` + - `ICLI_IBKR_PORT=4001` + - host and port are configured in the gateway settings and can be different for live and paper trading + - the gateway defaults to localhost-only binding and read-only mode + +- You can also configure the idle refresh time for toolbar quotes (in seconds): + - `ICLI_REFRESH=3.3` + +- You should specify the futures expiration quarter month for quotes and trades too: + - `ICLI_FUT_EXP=202109` + - we don't have a way to detect when you want to roll to the next quarter yet, so it needs to be manually specified + - also the setting is global for all futures quotes and transactions, so we don't currently support futures calendar spreads (but futures options calendar spreads should work since the dates are in the symbol names) + + +Configure environment settings as above, confirm the IBKR Gateway is started (and confirm whether you want read-only mode or full mode in addition to noting which port the gateway is opening for local connections), login to the IBKR Gateway (requires 2fa to the IBKR app on your phone), then run: + +```bash +ICLI_FUT_EXP=202109 ICLI_IBKR_PORT=[gateway localhost port] poetry run icli +``` + +You should see your account details showing in the large bottom toolbar along with a default set of quotes (assuming you have all the streaming market data permissions required). + +View all commands by just hitting enter on a blank line. + +View all commands by category by entering `?`. + +View per-command documentation by entering a command name followed by a question: `limit?` or `lim?` or `exec?` or `pos?` or `cancel?` etc. + +If you have any doubt about how a command may change your account, check the source for the command in `lang.py` yourself just to confirm the data workflow. + +## Caveats + +- the IBKR API doesn't allow any operations on fractional shares, so those orders must be handled by IBKR web, mobile, or TWS. +- It's best to have *no* standing orders placed from IBKR web, mobile, or TWS. All orders should be placed from the API itself due to how IBKR handles ["order ID binding" issues](https://interactivebrokers.github.io/tws-api/modifying_orders.html). + - for example, if you have a GTC REL order to SELL 100 AAPL at $200 minimum placed from IBKR mobile/web/tws which you expect to maybe hit in 3 months, connecting to the IBKR API will actually cancel and re-submit the live order every time you start your API client (instead of the expected behavior of only submitting once daily when the market opens). + - So, it's best to only place orders through the API endpoints if you are doing majority API-related trading. + - Also, you can always modify and cancel orders placed via API using the regular mobile/web/tws apps too. +- IBKR only allows one login per account across all platforms, so when your IBKR Gateway is running, you can't login to IBKR mobile/web without kicking the API gateway connection offline (so your API client will lose access to the IBKR network too). + - though, you can run an unlimited number of *local* clients connecting to the gateway. Useful for things like: if you wanted to develop your own quote viewing/graphing system while also using another api application for trading or for creating a live account balance dashboard, etc. +- IBKR supports many currencies and many countries and many exchanges, but currently `icli` uses USD and SMART exchange transactions for all orders (except for futures which use the correct futures exchange per future symbol). +- IBKR paper trading / sandbox interface doesn't support all features of a regular account, so you may get random errors ("invalid account code") you won't see on your live account. Also you'll probably be unable to use many built-in algo types with paper trading. The only safe paper trading order types appear to be direct limit and market orders. + +You should also be comfortable diving into the code if anything looks wonky to you. + +## System Limits + +`icli` is still limited by all regular IBKR policies including, but not limited to: + +- by default the gateway is in Read Only mode + - Configure -> Settings -> API -> Settings -> 'Read-Only API' +- IBKR has no concept of "buy to open" / "sell to open" / "sell to close" / "buy to close" — IBKR only sees BUY and SELL transactions. If you try to sell something you don't own, IBKR will execute the transaction as a new short position. If you try to sell *more* than something you own, IBKR will sell your position then also create a new short position. It's up to you to track whether you are buying and selling the quantities you expect. +- You should likely convert your account to the [PRO Tiered Commission Plan](https://www.interactivebrokers.com/en/index.php?f=1590) so you can receive transaction rebates, lowest margin fees, access to full 4am to 8pm trading hours, and hopefully receive the highest rebates and lowest commissions on the platform. +- you need to [pay for live market data](https://www.interactivebrokers.com/en/index.php?f=14193) to receive live streaming quotes + - at a minimum you will want "US Securities Snapshot and Futures Value Bundle" and "US Equity and Options Add-On Streaming Bundle" plus "OPRA Top of Book (L1)" for options pricing plus "US Futures Value PLUS Bundle" for non-delayed futures quotes (doesn't include VIX though) +- you are still limited to "[market data lines](https://www.interactivebrokers.com/en/index.php?f=14193#market-data-display)" where you are limited to 100 concurrent streaming quotes unless you either have a *uuuuuge* account balance, or unless you pay an additional $30 per month for +100 more top of book streaming quotes (but you can buy up to 10 quote booster ultra rare hologram packs, so the max limit on quotes is 100 * 10 + 100 = 1,100 symbols which would also give you access to open 11 concurrent DOM views too) +- you need to manually request access to trade anything not a stock or etf by default (penny stocks, options, bonds, futures, volatility products) +- you need to self-monitor your "[order efficiency ratio](https://ibkr.info/article/1343)" which means you need at least 1 executed trade for every 20 order create/modify/cancel requests you submit to IBKR (you always start the day with 20 free order credits) + - you can count your daily executed trades using the `executions` command (each row means +20 order credits for the day) + - there's no built-in mechanism to count how many cancel/modify operations happened in a day because IBKR makes those history rows vanish immediately when processed (we *could* create a counter to log them locally, but haven't had a reason to do so yet) +- IBKR has a fixed limit of 10,000 simultaneous active orders per account (which is a lot tbh) +- typical [FINRA legal restrictions](https://www.finra.org/investors/learn-to-invest/advanced-investing/day-trading-margin-requirements-know-rules) apply such as: + - if your account is under $25k, for equity symbols and equity options, you are limited to 3 same-day open/close trades per 5 trading days. + - your live day trades remaining count is visible in the cli toolbar + - when displayed, the day trades remaining count is updated in real time + - if your account is over $25k, the count will not display + - but, if your account has total value bouncing between say $24k and $26k, the limit will appear and vanish and appear again as your balance grows and shrinks above and below the $25k limit. + - you can hack around the "same-day open/close" limit somewhat with options by turning a winning single-leg option position into a butterfly then closing it all the next day. + - the $25k 3-per-5 restriction does not apply to futures or future options, so go wild and open then close 1 `/MES` 100 times a night on your $4k account (though, watch out for the $0.52 commission per trade). + - unlike other brokers, IBKR gives you full 4x day trade margin regardless of your account balance (because IBKR doesn't issue margin calls—their automated systems will try their best (assuming [oil doesn't go negative](https://www.financemagnates.com/forex/brokers/interactive-brokers-loss-from-oil-collapse-swelled-to-104-million/)) to liquidate your positions until you are margin compliant again). so, if your account has $8k equity, you can hold up to $32k of stock during the day (which must be reduced below overnight margin before close—and you are still limited to the 3-in-5 same-day open/close equity trading rules even if closing your 4x margin orders would create a 3-in-5 violation) +- the CBOE options [390 rule](https://support.tastyworks.com/support/solutions/articles/43000435379-what-is-the-390-professional-orders-rule-390-rule-) always applies + - basically, if you average more than 1 CBOE option order placed every minute for a month (the "390" rule is from 390 minutes being the 6.5 hour trading day; also the order doesn't have to execute, just be placed to count), your account will be [re-classified and everything will cost more for you](https://ibkr.info/node/1242) and you'll potentially [get worse executions](https://markets.cboe.com/us/equities/trading/offerings/retail_priority/) going forward. +- if you aren't deploying an aggressive temporal alpha thesis (i.e. st0nks go brrrr), your orders should be adjusted to not hit a bid/ask price exactly when submitted. Immediate execution is called a "[marketable order](https://ibkr.info/article/201)," and those get the worst commission (more aggressive == more expensive to execute). You can avoiding hitting waiting orders at exchanges manually by adjusting your price (bids lower or asks higher or target a wide midpoint) or you can use various IBKR algo order types which may prefer to not take liquidity immediately (and some IBKR algos you can command to *never* take liquidity for the best rebate probability). + - restated: you get the best commission rates (and sometimes rebates 😎) when your order is sitting on an exchange's limit order book then *somebody else's* order matches against your waiting order (meaning: the counterparty is being aggressive while you are providing passive liquidity to the market—but if you need to be aggressive, TAKE THE PRICE AND USE IT.) +- also please remember to not run out of money + + + +## Architecture + +Entry point for the CLI is [`__main__.py`](icli/__main__.py) which handles the event loop setup, environment variable reading, and app launching. + +The easiest way to launch the cli is via poetry in the repository directory: `poetry run icli` + +cli commands are processed in a prompt-toolkit loop managed by the somewhat too long `dorepl()` method of class `IBKRCmdlineApp` in [`cli.py`](icli/cli.py). + +cli commands are implemented in [`lang.py`](icli/lang.py) with each command being a class with custom argument definitions as organized by the [`mutil/dispatch.py`](https://github.com/mattsta/mutil/blob/main/mutil/dispatch.py) system. Check out the `OP_MAP` variable for how command names are mapped to categories and implementation classes. + +Your CLI session history is persisted in `~/.tplatcli_ibkr_history.{live,sandbox}` so you have search and up/down recall across sessions. + +All actions taken by [the underlying IBKR API wrapper](https://github.com/erdewit/ib_insync) are logged in a file named `icli-{timestamp}.log` so you can always review every action the API received (which will also be the log where you can view any series of order updates/modifications/cancels since IBKR removes all intermediate order states of orders after an order is complete). + +All times in the interface are normalized to display in US Eastern Time where pre-market hours are 0400-0928, market hours are 0930-1600, and after hours is 1600-2000 (with options trading 0930-1600, with certain etf and index options trading until 1615 every day). Futures operate under their [own weird futures hours](https://www.cmegroup.com/trading-hours.html) schedule, so enjoy trading your Wheat Options and [Mini-sized Wheat Futures](https://www.cmegroup.com/markets/agriculture/grains/mini-sized-wheat.html) between 1900-0745 and 0830-1345. + + + +### Notable Helpers + +[`futsexchanges.py`](icli/futsexchanges.py) contains a mostly auto-generated mapping of future symbols to their matching exchanges and full text descriptions. The mapping can be updated by extracting updated versions of [the table of IBKR futures](https://www.interactivebrokers.com/en/index.php?f=26662) using `generateFuturesMapping()`. The reason for this mapping is when entering a futures trade order, IBKR requires the exchange name where the futures symbol lives (i.e. there's no SMART futures router and futures only trade on their owning exchange), so we had to create a full lookup table on our own. + +Also note: some of the future symbol descriptions are manually modified after the automatic mapping download. Read end of the file for notes about which symbols need additional metadata or symbol changes due to conflicting names or multiplier inconsistencies (example: `BRR` bitcoin contract is one symbol, but microbitcoin is 0.1 multiplier, while standard is 5 multiplier, and for some reason IBKR didn't implement the micro as the standard `MBT` symbol, so you have to use it as `BRR` with explicit multiple). + +[`orders.py`](icli/orders.py) is a central location for defining IBKR order types and extracting order objects from specified order types using all [20+ poorly documented, conflicting, and multi-purpose optional metadata fields](https://interactivebrokers.github.io/tws-api/classIBApi_1_1Order.html) an order may need. + + +## TODO + +`icli` is still a work in progress and future features may include: + +- better handling of spread orders +- better handling of future entry and exit conditions +- improve visual representation of spreads so you can confirm credit vs. debit transactions +- enable quick placement of bracket orders +- extensible auto-trading hooks +- maybe daily performance reports or efficacy reports based on orders placed vs. modified vs. canceled vs. executed +- add hooks to forward trade notifications as mobile push notifications +- maybe add web interface or real time graph interface +- we may convert the environment variable config to command line params eventually (via click or fire) +- allow optional non-guaranteed spreads for larger accounts + - these let IBKR run each leg of a spread independently, but you may also not get a complete fill on the spread leading to margin compliance problems if your account isn't big enough +- enable setting more complex optional order conditions like don't fill before or after certain timestamps +- adjust sound infrastructure to play different sounds based on win vs loss vs major win vs major loss vs flat trade capital reclamation +- add cli trade pub/sub infrastructure so you could broadcast your trade actions live to other platforms / webpages / visibility outlets +- more features for "hands off" auto trading operations without needing full custom algo modes +- enable full custom algo modes +- tests? would be a major task to actually mock the IBKR API itself to inject and reply to commands for any CI system. + +## History + +This is the second trade CLI I've written (the first being a [tradier api cli](https://documentation.tradier.com/brokerage-api) which isn't public yet) because I wanted faster access to scalping options during high volatility times where seconds matter for good trade executions, but navigating apps or web page entry flows wasn't being the most efficient way to place orders. + +Writing an IBKR seemed a good way to create a rapid order entry system also capable of using the only public simple fee broker which offers complex [exchange-side and simulated order types](https://www.interactivebrokers.com/en/index.php?f=4985) via API like primary peg (relative orders), mini-algos like adaptive limit/market orders, peg to midpoint, snap to market/primary/midpoint, market with protection, etc. + +so here we are. + +## Contributions + +Feel free to open issues to suggest changes or submit your own PR changes or refactor any existing confusing flows into less coupled components. + +Immediate next-step areas of interest are: + +- increasing the ease of entering complex trade details without sending users into 8 levels of different nested menus to configure all settings correctly + - a next step could be writing a linear trade language parser to enable copy/paste between apps like: `OTOCO::BUY_100_TSLA210716C00700000_3.33:SELL_ALL_4.44:STOP_ALL_2.99` which would convert the line into a 3-leg bracket order then execute it all it one step. +- adding extensible custom algo hooks (atr chandelier exits, short/fast moving average crossover buy/sell conditions, etc) +- adding a listening port to accept requests from external applications (probably just a websocket server) so they can use a clean `icli` API to bridge the actual IBKR API +- improving documentation / writing tutorials / helping people not lose money diff --git a/icli/CANYON.MID b/icli/CANYON.MID new file mode 100644 index 0000000..9b7bdc3 Binary files /dev/null and b/icli/CANYON.MID differ diff --git a/icli/__init__.py b/icli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/icli/__main__.py b/icli/__main__.py new file mode 100755 index 0000000..b0aa4ed --- /dev/null +++ b/icli/__main__.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +from prompt_toolkit.patch_stdout import patch_stdout +from loguru import logger +from mutil import safeLoop # type: ignore +import asyncio +import icli.cli as cli +import sys + +from dotenv import dotenv_values +import os + +CONFIG_DEFAULT = dict( + ICLI_IBKR_HOST="127.0.0.1", ICLI_IBKR_PORT=4001, ICLI_REFRESH=3.33 +) + +CONFIG = {**CONFIG_DEFAULT, **dotenv_values(".env.icli"), **os.environ} + +try: + ACCOUNT_ID = CONFIG["ICLI_IBKR_ACCOUNT_ID"] +except: + logger.error( + "Sorry, please provide your IBKR Account ID [U...] in ICLI_IBKR_ACCOUNT_ID" + ) + sys.exit(0) + +HOST = CONFIG["ICLI_IBKR_HOST"] +PORT = int(CONFIG["ICLI_IBKR_PORT"]) +REFRESH = float(CONFIG["ICLI_REFRESH"]) + + +async def initcli(): + app = cli.IBKRCmdlineApp( + accountId=ACCOUNT_ID, toolbarUpdateInterval=REFRESH, host=HOST, port=PORT + ) + await app.setup() + if sys.stdin.isatty(): + # patch entire application with prompt-toolkit-compatible stdout + with patch_stdout(raw=True): + try: + await app.dorepl() + except SystemExit: + # known good exit condition + ... + else: + # NOT IMPLEMENTED HERE, HOLDOVER FROM TCLI + await app.consumeStdin() + + app.stop() + + +def runit(): + """Entry point for icli script and __main__ for entire package.""" + try: + asyncio.run(initcli()) + except KeyboardInterrupt: + # known good exit condition + ... + except: + logger.exception("bad bad so bad bad") + + +if __name__ == "__main__": + runit() diff --git a/icli/cli.py b/icli/cli.py new file mode 100644 index 0000000..b8d9609 --- /dev/null +++ b/icli/cli.py @@ -0,0 +1,1455 @@ +#!/usr/bin/env python3 + +original_print = print +from prompt_toolkit import print_formatted_text, Application + +# from prompt_toolkit import print_formatted_text as print +from prompt_toolkit.formatted_text import HTML + +import pathlib +from bs4 import BeautifulSoup + +# http://www.grantjenks.com/docs/diskcache/ +import diskcache + +from icli.futsexchanges import FUTS_EXCHANGE +import decimal +import sys + +from collections import Counter, defaultdict +from dataclasses import dataclass, field +import datetime +import os + +from typing import Union, Optional, Sequence, Any, Mapping + +import numpy as np + +import pendulum + +import pandas as pd + +# for automatic money formatting in some places +import locale + +locale.setlocale(locale.LC_ALL, "") + +import os + +# Tell pygame to not print a hello message when it is imported +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" + +# sounds! +import pygame + +import ib_insync +from ib_insync import ( + IB, + Contract, + Bag, + ComboLeg, + Ticker, + RealTimeBarList, + PnLSingle, + Order, + NewsBulletin, + NewsTick, +) +import pprint +import asyncio + +import logging +from loguru import logger + +import seaborn + +import icli.lang as lang +from icli.helpers import * # FUT_EXP is appearing from here +from mutil.numeric import fmtPrice, fmtPricePad +from mutil.timer import Timer +import tradeapis.buylang as buylang + +# Configure logger where the ib_insync live service logs get written. +# Note: if you have weird problems you don't think are being exposed +# in the CLI, check this log file for what ib_insync is actually doing. +logging.basicConfig( + level=logging.INFO, + filename=f"icli-{pendulum.now('US/Eastern')}.log", + format="%(asctime)s %(message)s", +) + +pp = pprint.PrettyPrinter(indent=4) + +# setup color gradients we use to show gain/loss of daily quotes +COLOR_COUNT = 100 +# palette 'RdYlGn' is a spectrum from low RED to high GREEN which matches +# the colors we want for low/negative (red) to high/positive (green) +MONEY_COLORS = seaborn.color_palette("RdYlGn", n_colors=COLOR_COUNT, desat=1).as_hex() + +# only keep lowest 25 and highest 25 elements since middle values are less distinct +MONEY_COLORS = MONEY_COLORS[:25] + MONEY_COLORS[-25:] + +# display order we want: RTY, ES, NQ, YM +FUT_ORD = dict(MES=-9, ES=-9, RTY=-10, M2K=-10, NQ=-8, MNQ=-8, MYM=-7, YM=-7) + +# A-Z, Z-A, translate between them (lowercase only) +ATOZ = "".join([chr(x) for x in range(ord("a"), ord("z") + 1)]) +ZTOA = ATOZ[::-1] +ATOZTOA_TABLE = str.maketrans(ATOZ, ZTOA) + + +def invertstr(x): + return x.translate(ATOZTOA_TABLE) + + +# Fields updated live for toolbar printing. +# Printed in the order of this list (the order the dict is created) +# Some math and definitions for values: +# https://www.interactivebrokers.com/en/software/tws/usersguidebook/realtimeactivitymonitoring/available_for_trading.htm +# https://ibkr.info/node/1445 +LIVE_ACCOUNT_STATUS = [ + # row 1 + "AvailableFunds", + "BuyingPower", + "Cushion", + "DailyPnL", + "DayTradesRemaining", + # The API returns these, but ib_insync isn't allowing them yet. + "DayTradesRemainingT+1", + "DayTradesRemainingT+2", + "DayTradesRemainingT+3", + "DayTradesRemainingT+4", + # row 2 + "ExcessLiquidity", + "FuturesPNL", + "GrossPositionValue", + "MaintMarginReq", + "OptionMarketValue", + # row 3 + "NetLiquidation", + "RealizedPnL", + "TotalCashValue", + "UnrealizedPnL", + "SMA", + # unpopulated: + # "Leverage", + # "HighestSeverity", +] + +STATUS_FIELDS = set(LIVE_ACCOUNT_STATUS) + + +def asink(x): + # don't use print_formatted_text() (aliased to print()) because it doesn't + # respect the patch_stdout() context manager we've wrapped this entire + # runtime around. If we don't have patch_stdout() guarantees, the interface + # rips apart with prompt and bottom_toolbar problems during async logging. + original_print(x, end="") + + +logger.remove() +logger.add(asink, colorize=True) + +# new log level to disable color bolding on INFO default +logger.level("FRAME", no=25) +logger.level("ARGS", no=40, color="") + + +def readableHTML(html): + """Return contents of 'html' with tags stripped and in a _reasonably_ + readable plain text format""" + + return re.sub(r"(\n[\s]*)+", "\n", bs4.BeautifulSoup(html).get_text()) + + +# logger.remove() +# logger.add(asink, colorize=True) + +# Create prompt object. +from prompt_toolkit import PromptSession +from prompt_toolkit.history import FileHistory, ThreadedHistory +from prompt_toolkit.application import get_app +from prompt_toolkit.shortcuts import set_title +import asyncio +import os + +stocks = ["IWM", "QQQ", "VXX", "AAPL", "SBUX", "TSM"] + +# Futures to exchange mappings: +# https://www.interactivebrokers.com/en/index.php?f=26662 +# Note: Use ES and RTY and YM for quotes because higher volume +# also curiously, MNQ has more volume than NQ? +# Volumes at: https://www.cmegroup.com/trading/equity-index/us-index.html +# ES :: MES +# RTY :: M2K +# YM :: MYM +# NQ :: MNQ +sfutures = { + "GLOBEX": ["ES", "RTY", "MNQ", "GBP"], # "HE"], + "ECBOT": ["YM"], # , "TN", "ZF"], + # "NYMEX": ["GC", "QM"], +} + +# Note: ContFuture is only for historical data; it can't quote or trade. +# So, all trades must use a manual contract month (quarterly) +futures = [ + Future(symbol=sym, lastTradeDateOrContractMonth=FUT_EXP, exchange=x, currency="USD") + for x, syms in sfutures.items() + for sym in syms +] + +# logger.info("futures are: {}", futures) + + +@dataclass +class IBKRCmdlineApp: + # Your IBKR Account ID (required) + accountId: str + + # number of seconds between refreshing the toolbar quote/balance views + # (more frequent updates is higher redraw CPU utilization) + toolbarUpdateInterval: float = 2.22 + + host: str = "127.0.0.1" + port: int = 4001 + + # initialized to True/False when we first see the account + # ID returned from the API which will tell us if this is a + # sandbox ID or True Account ID + isSandbox: Optional[bool] = None + + # The Connection + ib: IB = field(default_factory=IB) + + # State caches + quoteState: dict[str, Ticker] = field(default_factory=dict) + quoteContracts: dict[str, Contract] = field(default_factory=dict) + depthState: dict[Contract, Ticker] = field(default_factory=dict) + summary: dict[str, float] = field(default_factory=dict) + position: dict[str, float] = field(default_factory=dict) + order: dict[str, float] = field(default_factory=dict) + liveBars: dict[str, RealTimeBarList] = field(default_factory=dict) + pnlSingle: dict[str, PnLSingle] = field(default_factory=dict) + exiting: bool = False + ol: buylang.OLang = field(default_factory=buylang.OLang) + + # Specific dict of ONLY fields we show in the live account status toolbar. + # Saves us from sorting/filtering self.summary() with every full bar update. + accountStatus: dict[str, float] = field( + default_factory=lambda: dict( + zip(LIVE_ACCOUNT_STATUS, [None] * len(LIVE_ACCOUNT_STATUS)) + ) + ) + + # Cache all contractIds to names + conIdCache: Mapping[int, Contract] = field( + default_factory=lambda: diskcache.Cache("./cache-contracts") + ) + + def __post_init__(self): + # just use the entire IBKRCmdlineApp as our app state! + self.opstate = self + + async def qualify(self, *contracts) -> Union[list[Contract], None]: + """Qualify contracts against the IBKR allowed symbols. + + Mainly populates .localSymbol and .conId + + We also cache the results for ease of re-use and for mapping + contractIds back to names later.""" + + # Note: this is the ONLY place we use self.ib.qualifyContractsAsync(). + # All other usage should use self.qualify() so the cache is maintained. + got = await self.ib.qualifyContractsAsync(*contracts) + + # iterate resolved contracts and save them all + for contract in got: + # Populate the id to contract cache! + if contract.conId not in self.conIdCache: + # default 30 day expiration... + self.conIdCache.set(contract.conId, contract, expire=86400 * 30) + + return got + + def contractForPosition( + self, sym, qty: Optional[float] = None + ) -> Union[None, tuple[Contract, float, float]]: + """Returns matching portfolio position as (contract, size, marketPrice). + + Looks up position by symbol name and returns either provided quantity or total quantity. + If no input quantity, return total position size. + If input quantity larger than position size, returned size is capped to max position size.""" + portitems = self.ib.portfolio() + logger.info("Port is: {}", portitems) + contract = None + for pi in portitems: + # Note: using 'localSymbol' because for options, it includes + # the full OCC-like format, while contract.symbol will just + # be the underlying equity symbol. + if pi.contract.localSymbol == sym: + contract = pi.contract + + if qty is None: + qty = pi.position + elif abs(qty) > abs(pi.position): + qty = pi.position + + return contract, qty, pi.marketPrice + + return None + + async def contractForOrderRequest( + self, oreq: buylang.OrderRequest, exchange="SMART" + ) -> Optional[Contract]: + """Return a valid qualified contract for any order request. + + If order request has multiple legs, returns a Bag contract representing the spread. + If order request only has one symbol, returns a regular future/stock/option contract. + + If symbol(s) in order request are not valid, returns None.""" + + if oreq.isSpread(): + return await self.bagForSpread(oreq, exchange) + + if oreq.isSingle(): + contract = contractForName(oreq.orders[0].symbol, exchange=exchange) + await self.qualify(contract) + + # only return success if the contract validated + if contract.conId: + return contract + + return None + + # else, order request had no orders... + return None + + async def bagForSpread( + self, oreq: buylang.OrderRequest, exchange="SMART", currency="USD" + ) -> Optional[Bag]: + """Given a multi-leg OrderRequest, return a qualified Bag contract. + + If legs do not validate, returns None and prints errors along the way.""" + + # For IBKR spreads ("Bag" contracts), each leg of the spread is qualified + # then placed in the final contract instead of the normal approach of qualifying + # the final contract itself (because Bag contracts have Legs and each Leg is only + # a contractId we have to look up via qualify() individually). + contracts = [ + contractForName(s.symbol, exchange=exchange, currency=currency) + for s in oreq.orders + ] + await self.qualify(*contracts) + + if not all(c.conId for c in contracts): + logger.error("Not all contracts qualified!") + return None + + contractUnderlying = contracts[0].symbol + reqUnderlying = oreq.orders[0].underlying() + if contractUnderlying != reqUnderlying.lstrip("/"): + logger.error( + "Resolved symbol [{}] doesn't match order underlying [{}]?", + contractUnderlying, + reqUnderlying, + ) + return None + + if not all(c.symbol == contractUnderlying for c in contracts): + logger.error("All contracts must have same underlying for spread!") + return None + + # Iterate (in MATCHED PAIRS) the resolved contracts with their original order details + legs = [] + + # We use more explicit exchange mapping here since future options + # require naming their exchanges instead of using SMART everywhere. + useExchange: str + for c, o in zip(contracts, oreq.orders): + useExchange = c.exchange + leg = ComboLeg( + conId=c.conId, + ratio=o.multiplier, + action="BUY" if o.isBuy() else "SELL", + exchange=c.exchange, + ) + + legs.append(leg) + + return Bag( + symbol=contractUnderlying, + exchange=useExchange or exchange, + comboLegs=legs, + currency=currency, + ) + + def midpointBracketBuyOrder( + self, + isLong: bool, + qty: int, + ask: float, + stopPct: float, + profitPts: float = None, + stopPts: float = None, + ): + """Place a 3-sided order: + - Market with Protection to buy immediately (long) + - Profit taker: TRAIL LIT with trailStopPrice = (current ask + profitPts) + - Stop loss: STP PRT with trailStopPrice = (current ask - stopPts) + """ + + lower, upper = boundsByPercentDifference(ask, stopPct) + if isLong: + lossPrice = lower + trailStop = makeQuarter(ask - lower) + + openLimit = ask + 1 + + openAction = "BUY" + closeAction = "SELL" + else: + lossPrice = upper + trailStop = makeQuarter(upper - ask) + + openLimit = ask - 1 + + openAction = "SELL" + closeAction = "BUY" + + # TODO: up/down One-Cancels-All brackets: + # BUY if +5 pts, TRAIL STOP 3 PTS + # SELL if -5 pts, TRAIL STOP 3 PTS + if True: + # Note: these orders require MANUAL order ID because by default, + # the order ID is populated on .placeOrder(), but we need to + # reference it here for the seconday order to reference + # the parent order! + parent = Order( + orderId=self.ib.client.getReqId(), + action=openAction, + totalQuantity=qty, + transmit=False, + # orderType="MKT PRT", + orderType="LMT", + lmtPrice=openLimit, + outsideRth=True, + tif="GTC", + ) + + profit = Order( + orderId=self.ib.client.getReqId(), + action=closeAction, + totalQuantity=qty, + parentId=parent.orderId, + transmit=True, + orderType="TRAIL LIMIT", + outsideRth=True, + tif="GTC", + trailStopPrice=lossPrice, # initial trigger level if price falls immediately + lmtPriceOffset=0.75, # price offset for the limit order when stop triggers + auxPrice=trailStop, # trailing amount before stop triggers + ) + + loss = Order( + action=closeAction, + totalQuantity=qty, + parentId=parent.orderId, + transmit=True, + orderType="STP PRT", + auxPrice=lossPrice, + ) + + return [parent, profit] # , loss] + + def orderPriceForSpread(self, contracts: Sequence[Contract], positionSize: int): + """Given a set of contracts, attempt to find the closing order.""" + ot = self.ib.openTrades() + + contractIds = set([c.conId for c in contracts]) + # Use a list so we can collect multiple exit points for the same position. + ts = [] + for t in ot: + if not isinstance(t.contract, Bag): + continue + + legIds = set([c.conId for c in t.contract.comboLegs]) + if legIds == contractIds: + qty, price = t.orderStatus.remaining, t.order.lmtPrice + ts.append((qty, price)) + + # if only one and it's the full position, return without formatting + if len(ts) == 1: + if abs(int(positionSize)) == abs(ts[0][0]): + return ts[0][1] + + # else, break out by order size, sorted from smallest to largest exit prices + return sorted(ts, key=lambda x: abs(x[1])) + + def orderPriceForContract(self, contract: Contract, positionSize: int): + """Attempt to match an active closing order to an open position. + + Works for both total quantity closing and partial scale-out closing.""" + ot = self.ib.openTrades() + + # Use a list so we can collect multiple exit points for the same position. + ts = [] + for t in ot: + # t.order.action is "BUY" or "SELL" + opposite = "SELL" if positionSize > 0 else "BUY" + if ( + t.order.action == opposite + and t.contract.localSymbol == contract.localSymbol + ): + # Closing price is opposite sign of the holding quantity. + # (i.e. LONG positions are closed for a CREDIT (-) and + # SHORT positions are closed for a DEBIT (+)) + ts.append( + ( + int(t.orderStatus.remaining), + np.sign(positionSize) * -1 * t.order.lmtPrice, + ) + ) + + # if only one and it's the full position, return without formatting + if len(ts) == 1: + if abs(int(positionSize)) == abs(ts[0][0]): + return ts[0][1] + + # else, break out by order size, sorted from smallest to largest exit prices + return sorted(ts, key=lambda x: abs(x[1])) + + def currentQuote(self, sym): + q = self.quoteState[sym.upper()] + show = f"{q.contract.symbol}: bid {q.bid:,.2f} x {q.bidSize} ask {q.ask:,.2f} x {q.askSize} last {q.last:,.2f} x {q.lastSize}" + logger.info(show) + + return q.bid, q.ask + + def updatePosition(self, pos): + self.position[pos.contract.symbol] = pos + + def updateOrder(self, trade): + self.order[trade.contract.symbol] = trade + + # Only print update if this is regular runtime and not + # the "load all trades on startup" cycle + if self.connected: + logger.warning("Order update: {}", trade) + + def errorHandler(self, reqId, errorCode, errorString, contract): + # Official error code list: + # https://interactivebrokers.github.io/tws-api/message_codes.html + if errorCode in {1102, 2104, 2106, 2158, 202}: + # non-error status codes on startup + # also we ignore reqId here because it is always -1 + logger.info( + "API Status {}[code {}]: {}", + f"[orderId {reqId}] " if reqId else "", + errorCode, + errorString, + ) + else: + logger.error( + "API Error [orderId {}] [code {}]: {}{}", + reqId, + errorCode, + errorString, + f" for {contract}" if contract else "", + ) + + def cancelHandler(self, err): + logger.warning("Order canceled: {}", err) + + def commissionHandler(self, trade, fill, report): + # Only report commissions if connected (not when loading startup orders) + if not self.connected: + logger.warning("Ignoring commission because not connected...") + return + + # TODO: different sounds if PNL is a loss? + # different sounds for big wins vs. big losses? + if fill.execution.side == "BOT": + pygame.mixer.music.play() + elif fill.execution.side == "SLD": + pygame.mixer.music.play() + + logger.warning( + "Order {} commission: {} {} {} at {} (total {} of {}) (commission {} ({} each)){}", + fill.execution.orderId, + fill.execution.side, + fill.execution.shares, + fill.contract.localSymbol, + locale.currency(fill.execution.price), + fill.execution.cumQty, + trade.order.totalQuantity, + locale.currency(fill.commissionReport.commission), + locale.currency(fill.commissionReport.commission / fill.execution.shares), + f" (pnl {locale.currency(fill.commissionReport.realizedPNL)})" + if fill.commissionReport.realizedPNL + else "", + ) + + def newsBHandler(self, news: NewsBulletin): + logger.warning("News Bulletin: {}", readableHTML(news.message)) + + def newsTHandler(self, news: NewsTick): + logger.warning("News Tick: {}", news) + + def orderExecuteHandler(self, trade, fill): + logger.warning("Trade executed for {}", fill.contract.localSymbol) + if fill.execution.cumQty > 0: + if trade.contract.conId not in self.pnlSingle: + self.pnlSingle[trade.contract.conId] = self.ib.reqPnLSingle( + self.accountId, "", trade.contract.conId + ) + else: + # if quantity is gone, stop listening for updates and remove. + self.ib.cancelPnLSingle(self.pnlSingle[trade.contract.conId]) + del self.pnlSingle[trade.contract.conId] + + def tickersUpdate(self, tickr): + logger.info("Ticker update: {}", tickr) + + def updateSummary(self, v): + """Each row is populated after connection then continually + updated via subscription while the connection remains active.""" + # logger.info("Updating sumary... {}", v) + self.summary[v.tag] = v.value + + # regular accounts are U...; sanbox accounts are DU... (apparently) + # Some fields are for "All" accounts under this login, which don't help us here. + # TODO: find a place to set this once instead of checking every update? + if self.isSandbox is None and v.account != "All": + self.isSandbox = v.account.startswith("D") + + if v.tag in STATUS_FIELDS: + try: + self.accountStatus[v.tag] = float(v.value) + except: + # don't care, just keep going + pass + + def updatePNL(self, v): + """Kinda like summary, except account PNL values aren't summary events, + they are independent PnL events. shrug. + + Also note: we merge these into our summary dict instead of maintaining + an indepdent PnL structure.""" + + # TODO: keep moving average of daily PNL and trigger sounds/events + # if it spikes higher/lower. + # logger.info("Updating PNL... {}", v) + self.summary["UnrealizedPnL"] = v.unrealizedPnL + self.summary["RealizedPnL"] = v.realizedPnL + self.summary["DailyPnL"] = v.dailyPnL + + try: + self.accountStatus["UnrealizedPnL"] = float(v.unrealizedPnL) + self.accountStatus["RealizedPnL"] = float(v.realizedPnL) + self.accountStatus["DailyPnL"] = float(v.dailyPnL) + except: + # don't care, just keep going + pass + + def updatePNLSingle(self, v): + """Streaming individual position PnL updates. + + Must be requested per-position. + + The reqPnLSingle method is the only way to get + live 'dailyPnL' updates per position (updated once per second!).""" + + # logger.info("Updating PNL... {}", v) + # These are kept "live updated" too, so just save the + # return value after the subscription. + self.pnlSingle[v.conId] = v + + def bottomToolbar(self): + def fmtPrice2(n: float): + # Some prices may not be populated if they haven't + # happened yet (e.g. PNL values if no trades for the day yet, etc) + if not n: + n = 0 + + # if GTE $1 million, stop showing cents. + if n > 999_999.99: + return f"{n:>10,.0f}" + + return f"{n:>10,.2f}" + + def fmtPriceOpt(n): + if n: + # assume trading $0.01 to $99.99 range for options + return f"{n:>5,.2f}" + + return f"{n:>5}" + + # Fields described at: + # https://ib-insync.readthedocs.io/api.html#module-ib_insync.ticker + def formatTicker(c): + usePrice = c.marketPrice() + try: + percentUpFromLow = ( + abs(usePrice - c.low) / ((usePrice + c.low) / 2) + ) * 100 + percentUpFromClose = ( + ((usePrice - c.close) / ((usePrice + c.close) / 2)) * 100 + if c.close + else 0 + ) + except: + # price + (low or close) is zero... can't do that. + percentUpFromLow = 0 + percentUpFromClose = 0 + + def mkcolor( + n: float, vals: Union[str, list[str]], colorRanges: list[str] + ) -> Union[str, list[str]]: + def colorRange(x): + buckets = len(MONEY_COLORS) // len(colorRanges) + for idx, crLow in enumerate(colorRanges): + if x <= crLow: + return MONEY_COLORS[idx * buckets] + + # else, on the high end of the range, so use highest color + return MONEY_COLORS[-1] + + # no style if no value (or if nan%) + if n == 0 or n != n: + return vals + + # override for high values + if n >= 0.98: + useColor = "ansibrightblue" + else: + useColor = colorRange(n) + + if isinstance(vals, list): + return [f"{v}" for v in vals] + + # else, single thing we can print + return f"{vals}" + + def mkPctColor(a, b): + # fmt: off + colorRanges = [-0.98, -0.61, -0.33, -0.13, 0, 0.13, 0.33, 0.61, 0.98] + # fmt: on + return mkcolor(a, b, colorRanges) + + amtHigh = usePrice - c.high + amtLow = usePrice - c.low + amtClose = usePrice - c.close + # If there are > 1,000 point swings, stop displaying cents. + # also the point differences use the same colors as the percent differences + # because having fixed point color offsets doesn't make sense (e.g. AAPL moves $2 + # vs DIA moving $200) + + # if bidsize or asksize are > 100,000, just show "100k" instead of breaking + # the interface for being too wide + b_s = ( + f"{c.bidSize:>6,}" + if (c.bidSize < 100_000 or np.isnan(c.bidSize)) + else f"{c.bidSize // 1000:>5}k" + ) + a_s = ( + f"{c.askSize:>6,}" + if (c.askSize < 100_000 or np.isnan(c.askSize)) + else f"{c.askSize // 1000:>5}k" + ) + + bigboi = (len(c.contract.localSymbol) > 15) or c.contract.comboLegs + + if bigboi: + # if c.modelGreeks: + # mark = c.modelGreeks.optPrice + + if c.bid and c.bidSize and c.ask and c.askSize: + # weighted sum of bid/ask as midpoint + mark = ((c.bid * c.bidSize) + (c.ask * c.askSize)) / ( + c.bidSize + c.askSize + ) + else: + # IBKR reports "no bid" as -1. le sigh. + mark = (c.bid + c.ask) / 2 if c.bid > 0 else c.ask / 2 + + # For options, instead of using percent difference between + # prices, we use percent return over the low/close instead. + # e.g. if low is 0.05 and current is 0.50, we want to report + # a 900% multiple, not a 163% difference between the + # two numbers as we would report for normal stock price changes. + # Also note: we use 'mark' here because after hours, IBKR reports + # the previous day closing price as the current price, which clearly + # isn't correct since it ignores the entire most recent day. + bighigh = ((mark / c.high if c.high else 1) - 1) * 100 + biglow = ((mark / c.low if c.low else 1) - 1) * 100 + bigclose = ((mark / c.close if c.close else 1) - 1) * 100 + + pctBigHigh, amtBigHigh = mkPctColor( + bighigh, + [ + f"{bighigh:>7.2f}%", + f"{amtHigh:>7.2f}" if amtHigh < 1000 else f"{amtHigh:>7.0f}", + ], + ) + pctBigLow, amtBigLow = mkPctColor( + biglow, + [ + f"{biglow:>7.2f}%", + f"{amtLow:>7.2f}" if amtLow < 1000 else f"{amtLow:>7.0f}", + ], + ) + pctBigClose, amtBigClose = mkPctColor( + bigclose, + [ + f"{bigclose:>7.2f}%", + f"{amtClose:>7.2f}" if amtLow < 1000 else f"{amtClose:>7.0f}", + ], + ) + + if False: + pctUpLow, amtUpLow = mkPctColor( + percentUpFromLow, + [ + f"{percentUpFromLow:>7.2f}%", + f"{amtLow:>7.2f}" if amtLow < 1000 else f"{amtLow:>7.0f}", + ], + ) + pctUpClose, amtUpClose = mkPctColor( + percentUpFromClose, + [ + f"{percentUpFromClose:>7.2f}%", + f"{amtClose:>7.2f}" + if amtLow < 1000 + else f"{amtClose:>7.0f}", + ], + ) + + if c.lastGreeks and c.lastGreeks.undPrice: + und = c.lastGreeks.undPrice + strike = c.contract.strike + underlyingStrikeDifference = -(strike - und) / und * 100 + iv = c.lastGreeks.impliedVol + else: + und = None + underlyingStrikeDifference = None + iv = None + + # Note: we omit OPEN price because IBKR doesn't report it (for some reason?) + # greeks available as .bidGreeks, .askGreeks, .lastGreeks, .modelGreeks each as an OptionComputation named tuple + # '.halted' is either nan or 0 if NOT halted, so 'halted > 0' should be a safe check. + rowName: str + + # For all combos, we cache the ID to original symbol mapping + # after the contractId is resolved. + if c.contract.comboLegs: + # generate rows to look like: + # B 1 AAPL212121C000... + # S 2 .... + rns = [] + for x in c.contract.comboLegs: + contract = self.conIdCache[x.conId] + rns.append( + f"{x.action[0]} {x.ratio:2} {contract.localSymbol or contract.symbol}" + ) + + rowName = "\n".join(rns) + return " ".join( + [ + rowName, + f"{fmtPriceOpt(mark):>6}", + f" {fmtPriceOpt(c.bid):>} x {b_s} {fmtPriceOpt(c.ask):>} x {a_s} ", + "HALTED!" if c.halted > 0 else "", + ] + ) + else: + rowName = f"{c.contract.localSymbol or c.contract.symbol:<6}:" + + return " ".join( + [ + rowName, + f"[u {fmtPricePad(und, 8)} ({underlyingStrikeDifference or -0:>6,.2f}%)]", + f"[iv {iv or 0:.2f}]", + f"{fmtPriceOpt(mark)}", + # f"{fmtPriceOpt(usePrice)}", + f"({pctBigHigh} {amtBigHigh} {fmtPriceOpt(c.high)})", + f"({pctBigLow} {amtBigLow} {fmtPriceOpt(c.low)})", + f"({pctBigClose} {amtBigClose} {fmtPriceOpt(c.close)})", + # f"[h {fmtPriceOpt(c.high)}]", + # f"[l {fmtPriceOpt(c.low)}]", + f" {fmtPriceOpt(c.bid)} x {b_s} {fmtPriceOpt(c.ask)} x {a_s} ", + # f"[c {fmtPriceOpt(c.close)}]", + "HALTED!" if c.halted > 0 else "", + ] + ) + + pctUpLow, amtUpLow = mkPctColor( + percentUpFromLow, + [ + f"{percentUpFromLow:>5.2f}%", + f"{amtLow:>6.2f}" if amtLow < 1000 else f"{amtLow:>6.0f}", + ], + ) + pctUpClose, amtUpClose = mkPctColor( + percentUpFromClose, + [ + f"{percentUpFromClose:>6.2f}%", + f"{amtClose:>7.2f}" if amtLow < 1000 else f"{amtClose:>7.0f}", + ], + ) + + return f"{c.contract.localSymbol or c.contract.symbol:<6}: {fmtPricePad(usePrice)} ({pctUpLow} {amtUpLow}) ({pctUpClose} {amtUpClose}) {fmtPricePad(c.high)} {fmtPricePad(c.low)} {fmtPricePad(c.bid)} x {b_s} {fmtPricePad(c.ask)} x {a_s} {fmtPricePad(c.open)} {fmtPricePad(c.close)}" + + try: + pass + # logger.info("One future: {}", self.quoteState["ES"].dict()) + except: + pass + + try: + rowlen, _ = shutil.get_terminal_size() + + rowvals = [[]] + currentrowlen = 0 + DT = [] + for cat, val in self.accountStatus.items(): + # if val == 0: + # continue + + if cat.startswith("DayTrades"): + # the only field we treat as just an integer + + # skip field if is -1, meaning account is > $25k so + # there is no day trade restriction + if val == -1: + continue + + DT.append(int(val)) + + # wait until we accumulate all 5 day trade indicators + # before printing the day trades remaining count... + if len(DT) < 5: + continue + + section = "DayTradesRemaining" + # If ALL future day trade values are equal, only print the + # single value. + if all(x == DT[0] for x in DT): + value = f"{section:<20} {DT[0]:>14}" + else: + # else, there is future day trade divergence, + # so print all the days. + csv = ",".join([str(x) for x in DT]) + value = f"{section:<20} ({csv:>14})" + else: + # else, use our nice formatting + # using length 14 to support values up to 999,999,999.99 + value = f"{cat:<20} {fmtPrice2(val):>14}" + + vlen = len(value) + # "+ 4" because of the " " in the row entry join + if (currentrowlen + vlen + 4) < rowlen: + # append to current row + rowvals[-1].append(value) + currentrowlen += vlen + 4 + else: + # add new row, reset row length + rowvals.append([value]) + currentrowlen = vlen + + balrows = "\n".join(" ".join(x) for x in rowvals) + + def sortQuotes(x): + """Comparison function to sort quotes by specific types we want grouped together.""" + sym, quote = x + c = quote.contract + + # We want to sort futures first, and sort MES, MNQ, etc first. + if c.secType == "FUT": + priority = FUT_ORD[c.symbol] if c.symbol in FUT_ORD else 0 + return (0, priority, c.symbol) + + if c.secType == "OPT": + # options are medium last because they are wide + priority = 0 + return (2, priority, c.localSymbol) + + if c.secType == "FOP": + # future options are above other options... + priority = -1 + return (2, priority, c.localSymbol) + + if c.secType == "BAG": + # bags are last because their descriptions are big + priority = 0 + return (3, priority, c.symbol) + + # else, just by name. + # BUT we do these in REVERSE order since they + # are at the end of the table! + # (We create "reverse order" by translating all + # letters into their "inverse" where a == z, b == y, etc). + priority = 0 + return (1, priority, invertstr(c.symbol.lower())) + + now = str(pendulum.now("US/Eastern")) + + return HTML( + f"""{now}\n""" + + "\n".join( + [ + formatTicker(quote) + for sym, quote in sorted( + self.quoteState.items(), key=sortQuotes + ) + ] + ) + + "\n" + + balrows + ) + except: + logger.exception("qua?") + return HTML("No data yet...") # f"""{self.now:<40}\n""") + + async def qask(self, terms) -> Union[dict[str, Any], None]: + """Ask a questionary survey using integrated existing toolbar showing""" + result = dict() + extraArgs = dict(bottom_toolbar=self.bottomToolbar, refresh_interval=0.750) + for t in terms: + got = await t.ask(**extraArgs) + + # if user canceled, give up + # See: https://questionary.readthedocs.io/en/stable/pages/advanced.html#keyboard-interrupts + if got is None: + return None + + result[t.name] = got + + return result + + def levelName(self): + if self.isSandbox is None: + return "undecided" + + if self.isSandbox: + return "paper" + + return "live" + + async def dorepl(self): + # Setup... + + # wait until we start getting data from the gateway... + loop = asyncio.get_event_loop() + + dispatch = lang.Dispatch() + pygame.mixer.init() + + # TODO: could probably just be: pathlib.Path(__file__).parent + pygame.mixer.music.load( + pathlib.Path(os.path.abspath(__file__)).parent / "CANYON.MID" + ) + + contracts = [Stock(sym, "SMART", "USD") for sym in stocks] + contracts += futures + + def requestMarketData(): + logger.info("Requesting market data...") + for contract in contracts: + # Additional details can be requested: + # https://ib-insync.readthedocs.io/api.html#ib_insync.ib.IB.reqMktData + # https://interactivebrokers.github.io/tws-api/tick_types.html + # By default, only common fields are populated (so things like 13/26/52 week + # highs and lows aren't created unless requested via tick set 165, etc) + # Also can subscribe to live news feed per symbol with tick 292 (news result + # returned via tickNewsEvent callback, we think) + + # Tell IBKR API to return "last known good quote" if outside + # of regular market hours instead of giving us bad data. + # Must be called before each market data request! + self.ib.reqMarketDataType(2) + + tickFields = tickFieldsForContract(contract) + self.quoteState[contract.symbol] = self.ib.reqMktData( + contract, tickFields + ) + + self.quoteContracts[contract.symbol] = contract + + # Note: the Ticker returned by reqMktData() is updated in-place, so we can just + # read the object on a timer for the latest value(s) + + async def reconnect(): + # don't reconnect if an exit is requested + if self.exiting: + return + + logger.info("Connecting to IBKR API...") + while True: + try: + self.connected = False + + # NOTE: Client ID *MUST* be 0 to allow modification of + # existing orders (which get "re-bound" with a new + # order id when client 0 connects—but it *only* works + # for client 0) + + # Note: these are equivalent to the pattern: + # lambda row: self.updateSummary(row) + self.ib.accountSummaryEvent += self.updateSummary + self.ib.pnlEvent += self.updatePNL + self.ib.orderStatusEvent += self.updateOrder + self.ib.errorEvent += self.errorHandler + self.ib.cancelOrderEvent += self.cancelHandler + self.ib.commissionReportEvent += self.commissionHandler + self.ib.newsBulletinEvent += self.newsBHandler + self.ib.tickNewsEvent += self.newsTHandler + + # We don't use these event types because ib_insync keeps + # the objects "live updated" in the background, so everytime + # we read them on a refresh, the values are still valid. + # self.ib.pnlSingleEvent += self.updatePNLSingle + # self.ib.pendingTickersEvent += self.tickersUpdate + + # openOrderEvent is noisy and randomly just re-submits + # already static order details as new events. + # self.ib.openOrderEvent += self.orderOpenHandler + self.ib.execDetailsEvent += self.orderExecuteHandler + + await self.ib.connectAsync( + self.host, + self.port, + clientId=0, + readonly=False, + account=self.accountId, + ) + + logger.info( + "Connected! Current Request ID: {}", self.ib.client._reqIdSeq + ) + + self.connected = True + + self.ib.reqNewsBulletins(True) + + requestMarketData() + + # reset cached states on reconnect so we don't have stale + # data by mistake + self.summary.clear() + self.position.clear() + self.order.clear() + self.pnlSingle.clear() + + # Note: "PortfolioEvent" is fine here since we are using a single account. + # If you have multiple accounts, you want positionEvent (the IBKR API + # doesn't allow "Portfolio" to span accounts, but Positions can be reported + # from multiple accounts with one API connection apparently) + self.ib.updatePortfolioEvent += lambda row: self.updatePosition(row) + + # request live updates (well, once per second) of account and position values + self.ib.reqPnL(self.accountId) + + # Subscribe to realtime PnL updates for all positions in account + # Note: these are updated once per second per position! nice. + # TODO: add this to the account order/filling notifications too. + for p in self.ib.portfolio(): + self.pnlSingle[p.contract.conId] = self.ib.reqPnLSingle( + self.accountId, "", p.contract.conId + ) + + lookupBars = [ + Future( + symbol="MES", + exchange="GLOBEX", + lastTradeDateOrContractMonth=FUT_EXP, + ), + Future( + symbol="MNQ", + exchange="GLOBEX", + lastTradeDateOrContractMonth=FUT_EXP, + ), + ] + + if False: + self.liveBars = { + c.symbol: self.ib.reqRealTimeBars(c, 5, "TRADES", False) + for c in lookupBars + } + + # run some startup accounting subscriptions concurrently + await asyncio.gather( + self.ib.reqAccountSummaryAsync(), # self.ib.reqPnLAsync() + ) + break + except: + logger.error("Failed to connect to IB Gateway, trying again...") + # logger.exception("why?") + try: + await asyncio.sleep(3) + except: + logger.warning("Exit requested during sleep. Goodbye.") + sys.exit(0) + + try: + await reconnect() + except SystemExit: + # do not pass go, do not continue, throw the exit upward + sys.exit(0) + + set_title(f"{self.levelName().title()} Trader") + self.ib.disconnectedEvent += lambda: asyncio.create_task(reconnect()) + + session = PromptSession( + history=ThreadedHistory( + FileHistory( + os.path.expanduser(f"~/.tplatcli_ibkr_history.{self.levelName()}") + ) + ) + ) + + app = session.app + + async def updateToolbar(): + """Update account balances""" + try: + app.invalidate() + except: + # network error, don't update anything + pass + + loop.call_later( + self.toolbarUpdateInterval, lambda: asyncio.create_task(updateToolbar()) + ) + + loop.create_task(updateToolbar()) + + # Primary REPL loop + while True: + try: + text1 = await session.prompt_async( + f"{self.levelName()}> ", + bottom_toolbar=self.bottomToolbar, + # refresh_interval=3, + # mouse_support=True, + # completer=completer, # <-- causes not to be full screen due to additional dropdown space + complete_in_thread=True, + complete_while_typing=True, + ) + + # Attempt to run the command submitted into the prompt + cmd, *rest = text1.split(" ", 1) + with Timer(cmd): + result = await dispatch.runop( + cmd, rest[0] if rest else None, self.opstate + ) + + continue + + # Below are legacy commands either not copied over to lang.py + # yet, or were copied and we forgot to delete here. + # This was the original (temporary) command implementation + # before we used the mutil/dispatch.py abstraction. + if text1.startswith("fast"): + try: + cmd, symbol, action, qty, price = text1.split() + except: + logger.warning("Format: symbol BUY|SELL qty price") + continue + + action = action.upper() + + if qty.startswith("$"): + dollarSpend = int(qty[1:]) + assetPrice = float(price) + + # if is option, adjust for contract size... + if len(symbol) > 15: + assetPrice *= 100 + + qty = dollarSpend // assetPrice + + if action not in {"BUY", "SELL"}: + logger.error( + "Action must be BUY or SELL: symbol, action, qty, price" + ) + continue + + contract = contractForName(symbol) + logger.info( + "Placing order for {} {} at {} DAY via {}", + qty, + symbol, + price, + contract, + ) + + order = self.limitOrder(action, int(qty), float(price)) + trade = self.ib.placeOrder(contract, order) + logger.info("Placed: {}", pp.pformat(trade)) + elif text1 == "lmt": + ... + elif text1.startswith("rcheck "): + cmd, base, pct = text1.split() + base = float(base) + pct = float(pct) / 100 + + lower, upper = boundsByPercentDifference(base, pct) + lowmid = base - lower + logger.info( + "Range of {:,.2f} ± {:.4f}% is ({:,.2f}, {:,.2f}) with distance ±{:,.2f}", + base, + pct * 100, + lower, + upper, + lowmid, + ) + elif text1.startswith("fu"): + cmd, *rest = text1.split() + + if rest: + try: + symbol, side, qty, trail, *check = rest + except: + logger.error( + "fu [symbol] [side] [qty] [trail] [[checkonly]]" + ) + continue + + got = dict(Symbol=symbol, Side=side, Quantity=qty) + got["Percentage Stop / Trail"] = trail + got["Place Order"] = "Check Only" if check else "Live Order" + else: + stuff = [ + Q("Symbol"), + Q("Side", choices=["Buy", "Sell"]), + # Q("Order", choices=["LMT", "MKT", "STP"]), + Q("Quantity"), + Q("Percentage Stop / Trail"), + Q("Place Order", choices=["Check Only", "Live Order"]), + ] + got = await self.qask(stuff) + logger.info("Got: {}", got) + + try: + sym = got["Symbol"].upper() + qty = got["Quantity"] + isLong = got["Side"].title() == "Buy" + liveOrder = got["Place Order"] == "Live Order" + percentStop = float(got["Percentage Stop / Trail"]) / 100 + fxchg = FUTS_EXCHANGE[sym] + except: + logger.info("Canceled by lack of fields...") + continue + + bid, ask = self.currentQuote(sym) + (qualified,) = await self.qualify( + Future( + currency="USD", + symbol=sym, + exchange=fxchg.exchange, + lastTradeDateOrContractMonth=FUT_EXP, + ) + ) + logger.info("qual: {} for {}", qualified, fxchg.name) + + order = self.midpointBracketBuyOrder( + qty=qty, isLong=isLong, ask=ask, stopPct=percentStop + ) + + if liveOrder: + placed = [] + for o in order: + logger.info("Placing order: {}", o) + t = self.ib.placeOrder(qualified, o) + placed.append(t) + + logger.info("Placed: {}", placed) + else: + for o in order: + logger.info("Checking order: {}", o) + # what-if orders always transmit true, but they aren't live. + o.transmit = True + ordstate = await self.ib.whatIfOrderAsync(qualified, o) + logger.info("ordstate: {}", ordstate) + + elif text1 == "bars": + logger.info("bars: {}", self.liveBars) + + # reset bar cache so it doesn't grow forever... + for k, v in self.liveBars.items(): + v.clear() + elif text1.lower().startswith("buy "): + cmd, *rest = text1.split() + symbol, qty, algo, profit, stop = rest + qty = int(qty) + profit = float(profit) + stop = float(stop) + contract = Future( + currency="USD", + symbol="MES", + lastTradeDateOrContractMonth=FUT_EXP, + exchange="GLOBEX", + ) + order = self.midpointBracketBuyOrder(1, 5, 0.75) + elif text1 == "try": + logger.info("Ordering...") + logger.info("QS: {}", self.quoteState["ES"]) + contract = Stock("AMD", "SMART", "USD") + order = LimitOrder( + action="BUY", + totalQuantity=500, + lmtPrice=33.33, + algoStrategy="Adaptive", + algoParams=[TagValue("adaptivePriority", "Urgent")], + ) + ordstate = await self.ib.whatIfOrderAsync(contract, order) + logger.info("ordstate: {}", ordstate) + elif text1 == "tryf": + logger.info("Ordering...") + contract = Future( + currency="USD", + symbol="MES", + lastTradeDateOrContractMonth=FUT_EXP, + exchange="GLOBEX", + ) + order = LimitOrder( + action="BUY", + totalQuantity=770, + lmtPrice=400.75, + algoStrategy="Adaptive", + algoParams=[TagValue("adaptivePriority", "Urgent")], + ) + ordstate = await self.ib.whatIfOrderAsync(contract, order) + logger.info("ordstate: {}", ordstate) + + if not text1: + continue + except KeyboardInterrupt: + continue # Control-C pressed. Try again. + except EOFError: + logger.error("Exiting...") + self.exiting = True + break # Control-D pressed. + except BlockingIOError as bioe: + # this is noisy macOS problem if using a non-fixed + # uvloop and we don't care, but it will truncate or + # duplicate your output. + # solution: don't use uvloop or use a working uvloop + try: + logger.error("FINAL\n") + except: + pass + except Exception as err: + while True: + try: + logger.exception("Trying...") + break + except Exception as e2: + asyncio.sleep(1) + pass + + def stop(self): + self.ib.disconnect() + + async def setup(self): + pass diff --git a/icli/futsexchanges.py b/icli/futsexchanges.py new file mode 100644 index 0000000..f53fe94 --- /dev/null +++ b/icli/futsexchanges.py @@ -0,0 +1,429 @@ +from dataclasses import dataclass +from typing import * + + +@dataclass +class FutureSymbol: + symbol: str + exchange: str + name: str + multiplier: Union[float, str] = "" # ib_insync default is '' for none here + delayed: bool = False + + +# When 3.10 happens: +# Symbol: TypeAlias = str +Symbol = str + + +def generateFuturesMapping() -> dict[Symbol, FutureSymbol]: + """Generate mapping of: + - future symbol => exchange, name of future + using the IBKR margin tables (for common exchanges we want at least). + + The values should be fairly static, so we just cache a manual run as + the global FUTS_EXCHANGE in this file.""" + import pandas as pd + + futs = pd.read_html("https://www.interactivebrokers.com/en/index.php?f=26662") # type: ignore + fs = {} + for ex in futs: + for idx, row in ex.iterrows(): + # Note: we don't use the name of the columns because the IBKR tables + # use different table row names per section, which is annoying, + # but their orders remain stable across tables. + exchange = row[0] + symbol = row[1] + name = row[2] + if exchange in { + "ECBOT", + "CME", + "GLOBEX", + "CMECRYPTO", + "CFE", + "NYBOT", + "NYMEX", + }: + fs[row[1]] = FutureSymbol(symbol=symbol, exchange=exchange, name=name) + + return fs + + +# simple mapping from name of future to exchange for future. +# Used for generating ibkr api Future Contract specification since +# each symbol must have an exchange declared. +FUTS_EXCHANGE = { + "AMB90": FutureSymbol( + symbol="AMB90", + exchange="CFE", + name="CBOE Three-Month AMERIBOR Compound Average Rate Index", + ), + "IBXXIBHY": FutureSymbol( + symbol="IBXXIBHY", + exchange="CFE", + name="iBoxx iShares $ High Yield Corporate Bond Index TR", + ), + "IBXXIBIG": FutureSymbol( + symbol="IBXXIBIG", + exchange="CFE", + name="iBoxx iShares $ Investment Grade Corporate Bond Index TR", + ), + "ACD": FutureSymbol(symbol="ACD", exchange="GLOBEX", name="Australian dollar"), + "AUD": FutureSymbol(symbol="AUD", exchange="GLOBEX", name="Australian dollar"), + "AJY": FutureSymbol(symbol="AJY", exchange="GLOBEX", name="Australian dollar"), + "GBP": FutureSymbol(symbol="GBP", exchange="GLOBEX", name="British pound"), + "BRE": FutureSymbol( + symbol="BRE", exchange="GLOBEX", name="Brazilian Real in US Dollars" + ), + "CAD": FutureSymbol(symbol="CAD", exchange="GLOBEX", name="Canadian dollar"), + "CZK": FutureSymbol(symbol="CZK", exchange="GLOBEX", name="Czech koruna"), + "EUR": FutureSymbol( + symbol="EUR", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "ECK": FutureSymbol(symbol="ECK", exchange="GLOBEX", name="Czech koruna"), + "GE": FutureSymbol(symbol="GE", exchange="GLOBEX", name="EURODOLLARS"), + "EHF": FutureSymbol(symbol="EHF", exchange="GLOBEX", name="Hungarian forint"), + "EM": FutureSymbol( + symbol="EM", exchange="GLOBEX", name="1 Month LIBOR (Int. Rate)" + ), + "EPZ": FutureSymbol(symbol="EPZ", exchange="GLOBEX", name="Polish zloty"), + "GF": FutureSymbol(symbol="GF", exchange="GLOBEX", name="Feeder Cattle"), + "GSCI": FutureSymbol(symbol="GSCI", exchange="GLOBEX", name="S&P-GSCI Index"), + "HUF": FutureSymbol(symbol="HUF", exchange="GLOBEX", name="Hungarian forint"), + "JPY": FutureSymbol(symbol="JPY", exchange="GLOBEX", name="Japanese yen"), + "LE": FutureSymbol(symbol="LE", exchange="GLOBEX", name="Live Cattle"), + "HE": FutureSymbol(symbol="HE", exchange="GLOBEX", name="Lean Hogs"), + "MXP": FutureSymbol(symbol="MXP", exchange="GLOBEX", name="Mexican Peso"), + "NZD": FutureSymbol(symbol="NZD", exchange="GLOBEX", name="New Zealand dollar"), + "NKD": FutureSymbol(symbol="NKD", exchange="GLOBEX", name="NIKKEI 225"), + "PLN": FutureSymbol(symbol="PLN", exchange="GLOBEX", name="Polish zloty"), + "ZAR": FutureSymbol(symbol="ZAR", exchange="GLOBEX", name="South African Rand"), + "RF": FutureSymbol( + symbol="RF", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "RP": FutureSymbol( + symbol="RP", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "RUR": FutureSymbol( + symbol="RUR", exchange="GLOBEX", name="Russian Ruble in US Dollars" + ), + "RY": FutureSymbol( + symbol="RY", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "CHF": FutureSymbol(symbol="CHF", exchange="GLOBEX", name="Swiss franc"), + "SPX": FutureSymbol(symbol="SPX", exchange="GLOBEX", name="S&P 500 Stock Index"), + "ETHUSDRR": FutureSymbol( + symbol="ETHUSDRR", + exchange="CMECRYPTO", + name="CME CF Ether-Dollar Reference Rate", + ), + "AIGCI": FutureSymbol( + symbol="AIGCI", exchange="ECBOT", name="Bloomberg Commodity Index" + ), + "B1U": FutureSymbol( + symbol="B1U", + exchange="ECBOT", + name="30-Year Deliverable Interest Rate Swap Futures", + ), + "AC": FutureSymbol(symbol="AC", exchange="ECBOT", name="Ethanol -CME"), + "F1U": FutureSymbol( + symbol="F1U", + exchange="ECBOT", + name="5-Year Deliverable Interest Rate Swap Futures", + ), + "KE": FutureSymbol( + symbol="KE", exchange="ECBOT", name="Hard Red Winter Wheat -KCBOT-" + ), + "LIT": FutureSymbol( + symbol="LIT", exchange="ECBOT", name="2-Year Eris Swap Futures" + ), + "LIW": FutureSymbol( + symbol="LIW", exchange="ECBOT", name="5-Year Eris Swap Futures" + ), + "MYM": FutureSymbol(symbol="MYM", exchange="ECBOT", name="Micro E-mini DJIA"), + "N1U": FutureSymbol( + symbol="N1U", + exchange="ECBOT", + name="10-Year Deliverable Interest Rate Swap Futures", + ), + "DJUSRE": FutureSymbol( + symbol="DJUSRE", exchange="ECBOT", name="Dow Jones US Real Estate Index" + ), + "T1U": FutureSymbol( + symbol="T1U", + exchange="ECBOT", + name="2-Year Deliverable Interest Rate Swap Futures", + ), + "TN": FutureSymbol(symbol="TN", exchange="ECBOT", name="10-YR T-NOTES"), + "UB": FutureSymbol(symbol="UB", exchange="ECBOT", name="Ultra T-BONDS"), + "YC": FutureSymbol(symbol="YC", exchange="ECBOT", name="Mini Sized Corn Futures"), + "YK": FutureSymbol( + symbol="YK", exchange="ECBOT", name="Mini Sized Soybean Futures" + ), + "YW": FutureSymbol(symbol="YW", exchange="ECBOT", name="Mini Sized Wheat Futures"), + "YM": FutureSymbol(symbol="YM", exchange="ECBOT", name="MINI DJIA"), + "Z3N": FutureSymbol(symbol="Z3N", exchange="ECBOT", name="3-YR TREAS."), + "ZB": FutureSymbol(symbol="ZB", exchange="ECBOT", name="30-year T-BONDS"), + "ZC": FutureSymbol(symbol="ZC", exchange="ECBOT", name="Corn Futures"), + "ZF": FutureSymbol(symbol="ZF", exchange="ECBOT", name="5-YR TREAS."), + "ZL": FutureSymbol(symbol="ZL", exchange="ECBOT", name="Soybean Oil Futures"), + "ZM": FutureSymbol(symbol="ZM", exchange="ECBOT", name="Soybean Meal Futures"), + "ZN": FutureSymbol(symbol="ZN", exchange="ECBOT", name="10-YR TREAS."), + "ZO": FutureSymbol(symbol="ZO", exchange="ECBOT", name="Oat Futures"), + "ZQ": FutureSymbol(symbol="ZQ", exchange="ECBOT", name="30 Day Federal Funds"), + "ZR": FutureSymbol(symbol="ZR", exchange="ECBOT", name="Rough Rice Futures"), + "ZS": FutureSymbol(symbol="ZS", exchange="ECBOT", name="Soybean Futures"), + "ZT": FutureSymbol(symbol="ZT", exchange="ECBOT", name="2-YR TREAS."), + "ZW": FutureSymbol(symbol="ZW", exchange="ECBOT", name="Wheat Futures"), + "BQX": FutureSymbol( + symbol="BQX", exchange="GLOBEX", name="CME E-Mini NASDAQ Biotechnology" + ), + "BOS": FutureSymbol(symbol="BOS", exchange="GLOBEX", name="Boston Housing Index"), + "CB": FutureSymbol( + symbol="CB", exchange="GLOBEX", name="CME Cash-Settled Butter Futures" + ), + "CHI": FutureSymbol(symbol="CHI", exchange="GLOBEX", name="Chicago Housing Index"), + "CLP": FutureSymbol(symbol="CLP", exchange="GLOBEX", name="Chilean peso"), + "CJY": FutureSymbol(symbol="CJY", exchange="GLOBEX", name="Canadian dollar"), + "CNH": FutureSymbol(symbol="CNH", exchange="GLOBEX", name="United States dollar"), + "CSC": FutureSymbol(symbol="CSC", exchange="GLOBEX", name="Cheese"), + "CUS": FutureSymbol( + symbol="CUS", exchange="GLOBEX", name="Housing Index Composite" + ), + "DA": FutureSymbol(symbol="DA", exchange="GLOBEX", name="MILK CLASS III INDEX"), + "DEN": FutureSymbol(symbol="DEN", exchange="GLOBEX", name="Denver Housing Index"), + "DY": FutureSymbol(symbol="DY", exchange="GLOBEX", name="CME DRY WHEY INDEX"), + "E7": FutureSymbol( + symbol="E7", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "EAD": FutureSymbol( + symbol="EAD", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "ECD": FutureSymbol( + symbol="ECD", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "EMD": FutureSymbol( + symbol="EMD", exchange="GLOBEX", name="E-mini S&P Midcap 400 Futures" + ), + "NIY": FutureSymbol( + symbol="NIY", exchange="GLOBEX", name="Yen Denominated Nikkei 225 Index" + ), + "ES": FutureSymbol(symbol="ES", exchange="GLOBEX", name="MINI-S&P 500"), + "SPXESUP": FutureSymbol( + symbol="SPXESUP", exchange="GLOBEX", name="E-mini S&P 500 ESG" + ), + "GDK": FutureSymbol( + symbol="GDK", exchange="GLOBEX", name="Class IV Milk - 200k lbs" + ), + "NF": FutureSymbol(symbol="NF", exchange="GLOBEX", name="NON FAT DRY MILK INDEX"), + "IBAA": FutureSymbol(symbol="IBAA", exchange="GLOBEX", name="Bovespa Index - USD"), + "ILS": FutureSymbol( + symbol="ILS", exchange="GLOBEX", name="Israeli Shekel in US Dollar" + ), + "J7": FutureSymbol(symbol="J7", exchange="GLOBEX", name="Japanese yen"), + "KRW": FutureSymbol(symbol="KRW", exchange="GLOBEX", name="Korean Won"), + "LAV": FutureSymbol( + symbol="LAV", exchange="GLOBEX", name="Las Vegas Housing Index" + ), + "LAX": FutureSymbol( + symbol="LAX", exchange="GLOBEX", name="Los Angeles Housing Index" + ), + "LB": FutureSymbol(symbol="LB", exchange="GLOBEX", name="Random Length Lumber"), + "M2K": FutureSymbol( + symbol="M2K", exchange="GLOBEX", name="Micro E-mini Russell 2000" + ), + "M6A": FutureSymbol(symbol="M6A", exchange="GLOBEX", name="Australian dollar"), + "M6B": FutureSymbol(symbol="M6B", exchange="GLOBEX", name="British pound"), + "M6E": FutureSymbol( + symbol="M6E", exchange="GLOBEX", name="European Monetary Union Euro" + ), + "MCD": FutureSymbol(symbol="MCD", exchange="GLOBEX", name="Canadian dollar"), + "MES": FutureSymbol(symbol="MES", exchange="GLOBEX", name="Micro E-mini S&P 500"), + "MIA": FutureSymbol(symbol="MIA", exchange="GLOBEX", name="Miami Housing Index"), + "MIR": FutureSymbol(symbol="MIR", exchange="GLOBEX", name="Indian Rupee"), + "MJY": FutureSymbol(symbol="MJY", exchange="GLOBEX", name="Japanese yen"), + "MNH": FutureSymbol(symbol="MNH", exchange="GLOBEX", name="United States dollar"), + "MNQ": FutureSymbol( + symbol="MNQ", exchange="GLOBEX", name="Micro E-mini NASDAQ-100" + ), + "MSF": FutureSymbol(symbol="MSF", exchange="GLOBEX", name="Swiss franc"), + "NOK": FutureSymbol(symbol="NOK", exchange="GLOBEX", name="Norwegian krone"), + "NQ": FutureSymbol(symbol="NQ", exchange="GLOBEX", name="NASDAQ E-MINI"), + "NYM": FutureSymbol(symbol="NYM", exchange="GLOBEX", name="New York Housing Index"), + "PJY": FutureSymbol(symbol="PJY", exchange="GLOBEX", name="British pound"), + "PSF": FutureSymbol(symbol="PSF", exchange="GLOBEX", name="British pound"), + "RMB": FutureSymbol( + symbol="RMB", + exchange="GLOBEX", + name="CME Chinese Renminbi in US Dollar Cross Rate", + ), + "RME": FutureSymbol( + symbol="RME", exchange="GLOBEX", name="CME Chinese Renminbi in Euro Cross Rate" + ), + "RS1": FutureSymbol( + symbol="RS1", exchange="GLOBEX", name="E-mini Russell 1000 Index Futures" + ), + "RSG": FutureSymbol( + symbol="RSG", exchange="GLOBEX", name="E-mini Russell 1000 Growth Index Futures" + ), + "RSV": FutureSymbol( + symbol="RSV", exchange="GLOBEX", name="E-Mini Russell 1000 Value Index Futures" + ), + "RTY": FutureSymbol( + symbol="RTY", exchange="GLOBEX", name="E-Mini Russell 2000 Index" + ), + "SPXDIVAN": FutureSymbol( + symbol="SPXDIVAN", + exchange="GLOBEX", + name="S&P 500 Dividend Points (Annual) Index", + ), + "SDG": FutureSymbol( + symbol="SDG", exchange="GLOBEX", name="San Diego Housing Index" + ), + "SEK": FutureSymbol(symbol="SEK", exchange="GLOBEX", name="Swedish krona"), + "SFR": FutureSymbol( + symbol="SFR", exchange="GLOBEX", name="San Francisco Housing Index" + ), + "SGX": FutureSymbol( + symbol="SGX", exchange="GLOBEX", name="S&P 500 / Citigroup Growth Index" + ), + "SIR": FutureSymbol(symbol="SIR", exchange="GLOBEX", name="Indian Rupee"), + "SJY": FutureSymbol(symbol="SJY", exchange="GLOBEX", name="Swiss franc"), + "SMC": FutureSymbol( + symbol="SMC", exchange="GLOBEX", name="E-Mini S&P SmallCap 600 Futures" + ), + "SONIA": FutureSymbol( + symbol="SONIA", exchange="GLOBEX", name="Sterling Overnight Index Average" + ), + "SOFR1": FutureSymbol( + symbol="SOFR1", + exchange="GLOBEX", + name="Secured Overnight Financing Rate 1-month average of rates", + ), + "SOFR3": FutureSymbol( + symbol="SOFR3", + exchange="GLOBEX", + name="Secured Overnight Financing Rate 3-month average of rates", + ), + "SVX": FutureSymbol( + symbol="SVX", exchange="GLOBEX", name="S&P 500 / Citigroup Value Index" + ), + "WDC": FutureSymbol( + symbol="WDC", exchange="GLOBEX", name="Washington DC Housing Index" + ), + "IXB": FutureSymbol( + symbol="IXB", exchange="GLOBEX", name="Materials Select Sector Index" + ), + "IXE": FutureSymbol( + symbol="IXE", exchange="GLOBEX", name="Energy Select Sector Index" + ), + "IXM": FutureSymbol( + symbol="IXM", exchange="GLOBEX", name="Financial Select Sector Index" + ), + "IXI": FutureSymbol( + symbol="IXI", exchange="GLOBEX", name="Industrial Select Sector Index" + ), + "IXT": FutureSymbol( + symbol="IXT", exchange="GLOBEX", name="Technology Select Sector Index -" + ), + "IXR": FutureSymbol( + symbol="IXR", exchange="GLOBEX", name="Consumer Staples Select Sector Index" + ), + "IXRE": FutureSymbol( + symbol="IXRE", exchange="GLOBEX", name="Real Estate Select Sector Index" + ), + "IXU": FutureSymbol( + symbol="IXU", exchange="GLOBEX", name="Utilities Select Sector Index" + ), + "IXV": FutureSymbol( + symbol="IXV", exchange="GLOBEX", name="Health Care Select Sector Index" + ), + "IXY": FutureSymbol( + symbol="IXY", + exchange="GLOBEX", + name="Consumer Discretionary Select Sector Index", + ), + "CC": FutureSymbol(symbol="CC", exchange="NYBOT", name="Cocoa NYBOT"), + "CT": FutureSymbol(symbol="CT", exchange="NYBOT", name="Cotton No. 2"), + "DX": FutureSymbol(symbol="DX", exchange="NYBOT", name="NYBOT US Dollar FX"), + "NYFANG": FutureSymbol(symbol="NYFANG", exchange="NYBOT", name="NYSE FANG+ Index"), + "KC": FutureSymbol(symbol="KC", exchange="NYBOT", name='Coffee "C"'), + "OJ": FutureSymbol(symbol="OJ", exchange="NYBOT", name='FC Orange Juice "A"'), + "RS": FutureSymbol(symbol="RS", exchange="NYBOT", name="Canola"), + "SB": FutureSymbol(symbol="SB", exchange="NYBOT", name="Sugar No. 11"), + "SF": FutureSymbol(symbol="SF", exchange="NYBOT", name="Sugar #16 112000 lbs"), + "ALI": FutureSymbol(symbol="ALI", exchange="NYMEX", name="NYMEX Aluminum Index"), + "BB": FutureSymbol( + symbol="BB", exchange="NYMEX", name="NYMEX Brent Financial Futures Index" + ), + "BZ": FutureSymbol( + symbol="BZ", exchange="NYMEX", name="Brent Crude Oil - Last Day" + ), + "CL": FutureSymbol(symbol="CL", exchange="NYMEX", name="Crude oil"), + "GC": FutureSymbol(symbol="GC", exchange="NYMEX", name="Gold"), + "HG": FutureSymbol(symbol="HG", exchange="NYMEX", name="Copper"), + "HH": FutureSymbol( + symbol="HH", exchange="NYMEX", name="Nautral Gas Last Day Financial Future" + ), + "HO": FutureSymbol(symbol="HO", exchange="NYMEX", name="Heating Oil"), + "HP": FutureSymbol( + symbol="HP", + exchange="NYMEX", + name="Natural Gas Penultimate Financial Futures Index", + ), + "HRC": FutureSymbol( + symbol="HRC", exchange="NYMEX", name="Hot-Rolled Coil Steel Index" + ), + "MGC": FutureSymbol(symbol="MGC", exchange="NYMEX", name="E-Micro Gold"), + "NG": FutureSymbol(symbol="NG", exchange="NYMEX", name="Natural gas"), + "PA": FutureSymbol(symbol="PA", exchange="NYMEX", name="NYMEX Palladium Index"), + "PL": FutureSymbol(symbol="PL", exchange="NYMEX", name="NYMEX Platinum Index"), + "QC": FutureSymbol(symbol="QC", exchange="NYMEX", name="Copper"), + "QG": FutureSymbol(symbol="QG", exchange="NYMEX", name="Natural gas E-Mini"), + "QH": FutureSymbol(symbol="QH", exchange="NYMEX", name="Heating Oil E-Mini"), + "QI": FutureSymbol(symbol="QI", exchange="NYMEX", name="Silver Mini"), + "QM": FutureSymbol(symbol="QM", exchange="NYMEX", name="Crude oil E-Mini"), + "QO": FutureSymbol(symbol="QO", exchange="NYMEX", name="Gold"), + "QU": FutureSymbol(symbol="QU", exchange="NYMEX", name="Unleaded Gasoline E-Mini"), + "RB": FutureSymbol(symbol="RB", exchange="NYMEX", name="RBOB Gasoline"), + "SGC": FutureSymbol( + symbol="SGC", + exchange="NYMEX", + name="Shanghai Gold Exchange Gold Benchmark PM Price Index - CNH Futures", + ), + "SGUF": FutureSymbol( + symbol="SGUF", + exchange="NYMEX", + name="Shanghai Gold Exchange Gold Benchmark PM Price Index - USD Futures", + ), + "SI": FutureSymbol(symbol="SI", exchange="NYMEX", name="Silver"), + "TT": FutureSymbol(symbol="TT", exchange="NYMEX", name="NYMEX Cotton index"), + "UX": FutureSymbol(symbol="UX", exchange="NYMEX", name="NYMEX Uranium Index"), + "SP": FutureSymbol(symbol="SP", exchange="GLOBEX", name="S&P 500"), + "SIL": FutureSymbol(symbol="SIL", exchange="NYMEX", name="Silver"), + "TF": FutureSymbol(symbol="TF", exchange="NYBOT", name="RUSSELL 2000"), + # NOTABLE MANUAL EXCEPTIONS TO THE ABOVE: + # IBKR uses the SAME SYMBOL for bitcoin futures and micro bitcoin futures, with + # the only difference being the multiplier requirement. + # We distinguish our usable names via /BTC for full and /MBT for micros. + "BTC": FutureSymbol( + symbol="BRR", + exchange="CMECRYPTO", + multiplier=5, + name="CME CF Bitcoin Reference Rate", + ), + "MBT": FutureSymbol( + symbol="BRR", + exchange="CMECRYPTO", + multiplier=0.1, + name="CME CF Micro Bitcoin Reference Rate", + ), + # We can't figure out which data package enables VIX access, so use delayed quotes. + "VIX": FutureSymbol(symbol="VIX", exchange="CFE", name="CBOE Volatility Index"), + "VXM": FutureSymbol( + symbol="VXM", + delayed=True, + exchange="CFE", + name="Mini Cboe Volatility Index Futures", + ), +} diff --git a/icli/helpers.py b/icli/helpers.py new file mode 100644 index 0000000..49bd356 --- /dev/null +++ b/icli/helpers.py @@ -0,0 +1,310 @@ +""" A refactor-base for splitting out common helpers between cli and lang """ + +import ib_insync # just for UNSET_DOUBLE +from ib_insync import Stock, Future, Option, Warrant, FuturesOption + +from icli.futsexchanges import FUTS_EXCHANGE +import pandas as pd +import numpy as np +import pendulum + +from dataclasses import dataclass, field +import questionary +from questionary import Choice + +from typing import * + +from loguru import logger +import shutil +from dotenv import dotenv_values +import os + +# TODO: detect this automatically: +FU_DEFAULT = dict(ICLI_FUT_EXP=202109) +FU_CONFIG = {**FU_DEFAULT, **dotenv_values(".env.icli"), **os.environ} + +# FUT_EXP = "202106" +FUT_EXP = FU_CONFIG["ICLI_FUT_EXP"] + + +def contractForName(sym, exchange="SMART", currency="USD"): + """Convert a single text symbol into an ib_insync contract. + + Text symbols are assumed to be one of: + - Future if symbol begins with '/' + - Option if symbol is > 15 characters long + - Future Option if symbol is large *and* starts with '/' + - Stock otherwise + """ + # TODO: how to specify warrants/equity options/future options/spreads/bonds/tbills/etc? + if sym.startswith("/"): + sym = sym[1:] + if len(sym) > 15: + # Is Future Option! FOP! + symbol = sym[:-15] + + body = sym[-15:] + date = "20" + body[:6] + right = body[-9] # 'C' + + if right not in {"C", "P"}: + raise Exception(f"Invalid option format right: {right} in {sym}") + + price = int(body[-8:]) # 320000 (leading 0s get trimmed automatically) + strike = price / 1000 # 320.0 + + fxchg = FUTS_EXCHANGE[symbol] + contract = FuturesOption( + currency=currency, + symbol=fxchg.symbol, + exchange=fxchg.exchange, + multiplier=fxchg.multiplier, + strike=strike, + right=right, + lastTradeDateOrContractMonth=date, + ) + else: + # else, is regular future + fxchg = FUTS_EXCHANGE[sym] + contract = Future( + currency=currency, + symbol=fxchg.symbol, + exchange=fxchg.exchange, + multiplier=fxchg.multiplier, + lastTradeDateOrContractMonth=FUT_EXP, + ) + elif len(sym) > 15: + # looks like: COIN210430C00320000 + symbol = sym[:-15] # COIN + body = sym[-15:] # 210430C00320000 + + # Note: Not year 2100+ compliant! + # 20 + YY + MM + DD + date = "20" + body[:6] + + right = body[-9] # 'C' + + if right not in {"C", "P"}: + raise Exception(f"Invalid option format right: {right} in {sym}") + + price = int(body[-8:]) # 320000 (leading 0s get trimmed automatically) + strike = price / 1000 # 320.0 + + contract = Option( + symbol=symbol, + lastTradeDateOrContractMonth=date, + strike=strike, + right=right, + exchange=exchange, + currency=currency, + ) + else: + # TODO: warrants, bonds, bills, etc + contract = Stock(sym, exchange, currency) + + return contract + + +def tickFieldsForContract(contract) -> str: + extraFields = [] + if isinstance(contract, Stock): + # 104: + # "The 30-day historical volatility (currently for stocks)." + # 106: + # "The IB 30-day volatility is the at-market volatility estimated + # for a maturity thirty calendar days forward of the current trading + # day, and is based on option prices from two consecutive expiration + # months." + # 236: + # "Number of shares available to short" + # "Shortable: < 1.5, not availabe + # > 1.5, if shares can be located + # > 2.5, enough shares are available (>= 1k)" + extraFields += [104, 106, 236] + + # yeah, the API wants a CSV for the tick list. sigh. + tickFields = ",".join([str(x) for x in extraFields]) + + # logger.info("[{}] Sending fields: {}", contract, tickFields) + return tickFields + + +def parseContractOptionFields(contract, d): + # logger.info("contract is: {}", o.contract) + if isinstance(contract, Warrant) or isinstance(contract, Option): + try: + d["date"] = pendulum.parse(contract.lastTradeDateOrContractMonth).date() + except: + logger.error("Row didn't have a good date? {}", contract) + return + d["strike"] = contract.strike + d["PC"] = contract.right + else: + # populate columns for non-contracts/warrants too so the final + # column-order generator still works. + d["PC"] = None + d["strike"] = None + d["date"] = None + + +def sortLocalSymbol(s): + """Given tuple of (occ date/right/strike, symbol) return + tuple of (occ date, symbol)""" + + return (s[0][:6], s[1]) + + +def portSort(p): + """sort portfolioItem 'p' by symbol if stock or by (expiration, symbol) if option""" + s = tuple(reversed(p.contract.localSymbol.split())) + if len(s) == 1: + return s + + # if option, sort by DATE then SYMBOL (i.e. don't sort by (date, type, strike) complex + return sortLocalSymbol(s) + + +def tradeOrderCmp(o): + """Return the sort key for a trade representing a live order. + + The goal is to sort by: + - BUY / SELL + - DATE (if has date, expiration, option, warrant, etc) + - SYMBOL + + Sorting is also flexible where if no date is available, the sort still works fine.""" + + # Sort all options by expiration first then symbol + # (no impact on symbols without expirations) + useSym = o.contract.symbol + useName = useSym + useKey = o.contract.localSymbol.split() + useDate = -1 + + if useKey: + useName = useKey[0] + if len(useKey) == 2: + useDate = useKey[1] + else: + # the 'localSymbol' date is 2 digit years while the 'lastTradeDateOrContractMonth' is + # four digit years, so to compare, strip the leading '20' from LTDOCM + useDate = o.contract.lastTradeDateOrContractMonth[2:] + + return (useDate, useSym, useName) + + +def boundsByPercentDifference(mid: float, percent: float) -> tuple[float, float]: + """Returns the lower and upper percentage differences from 'mid'. + + Percentage is given as a full decimal percentage. + Example: 0.25% must be provided as 0.0025""" + # Obviously doesn't work for mid == 0 or percent == 2, but we shouldn't + # encounter those values under normal usage. + + # This is just the percentage difference between two prices equation + # re-solved for a and b from: (a - b) / ((a + b) / 2) = percent difference + lower = -(mid * (percent - 2)) / (percent + 2) + upper = -(mid * (percent + 2)) / (percent - 2) + return (makeQuarter(lower), makeQuarter(upper)) + + +def strFromPositionRow(o): + """Return string describing an order (for quick review when canceling orders). + + As always, 'averageCost' field is for some reason the cost-per-contract field while + 'marketPrice' is the cost per share, so we manually convert it into the expected + cost-per-share average cost for display.""" + + useAvgCost = ( + o.averageCost / 100 if len(o.contract.localSymbol) > 15 else o.averageCost + ) + return f"{o.contract.localSymbol} :: {o.contract.secType} {o.position:,.2f} MKT:{o.marketPrice:,.2f} CB:{useAvgCost:,.2f} :: {o.contract.conId}" + + +def isset(x: float) -> bool: + """Sadly, ib_insync API uses FLOAT_MAX to mean "number is unset" instead of + letting numeric fields be Optional[float] where we could just check for None. + + So we have to directly compare against another value to see if a returned float + is a _set_ value or just a placeholder for the default value. le sigh.""" + return x != ib_insync.util.UNSET_DOUBLE + + +def makeQuarter(x) -> float: + # TODO: replace with mutil.numeric.roundnear for 0.25 and 1 + + """Convert any price to end in .00, 0.25, 0.50, or 0.75 to match + the tick intervals required for futures. + + MES ticks by $0.25, but MYM ticks only by $1, MNQ ticks by $0.25 + + Note: this rounds everything down. Could use ceil to round up if necessary.""" + return round(round(x * 4) / 4, 2) + + +@dataclass +class Q: + """Self-asking series of prompts.""" + + name: Optional[str] = None + msg: Optional[str] = None + choices: Optional[Sequence] = None + value: str = field(default_factory=str) + + def __post_init__(self): + # Allow flexiblity with assigning msg/name if they are just the same + if not self.msg: + self.msg = self.name + + if not self.name: + self.name = self.msg + + def ask(self, **kwargs): + """Prompt user based on types provided.""" + if self.choices: + # Note: no kwargs on .select() because .select() + # is injecting its own bottom_toolbar for error reporting, + # even though it never seems to use it? + # See: questionary/prompts/common.py create_inquier_layout() + return questionary.select( + message=self.msg, + choices=self.choices, + use_indicator=True, + use_shortcuts=True, + use_arrow_keys=True, + # **kwargs, + ).ask_async() + + return questionary.text(self.msg, default=self.value, **kwargs).ask_async() + + +@dataclass +class CB: + """Self-asking series of prompts.""" + + name: Optional[str] = None + msg: Optional[str] = None + choices: Optional[Sequence] = None + + def __post_init__(self): + # Allow flexiblity with assigning msg/name if they are just the same + if not self.msg: + self.msg = self.name + + if not self.name: + self.name = self.msg + + def ask(self, **kwargs): + """Prompt user based on types provided.""" + if self.choices: + # Note: no kwargs on .select() because .select() + # is injecting its own bottom_toolbar for error reporting, + # even though it never seems to use it? + # See: questionary/prompts/common.py create_inquier_layout() + return questionary.checkbox( + message=self.msg, + choices=self.choices, + # **kwargs, + ).ask_async() + + return questionary.text(self.msg, **kwargs).ask_async() diff --git a/icli/lang.py b/icli/lang.py new file mode 100644 index 0000000..bf6ca50 --- /dev/null +++ b/icli/lang.py @@ -0,0 +1,1354 @@ +from dataclasses import dataclass, field +from typing import * +import enum + +from ib_insync import Bag, Contract + +from collections import Counter, defaultdict + +import mutil.dispatch +from mutil.dispatch import DArg +from mutil.numeric import fmtPrice +from mutil.frame import printFrame + +import pandas as pd +import numpy as np + +from loguru import logger +from icli.helpers import * +import icli.orders as orders +import tradeapis.buylang as buylang + +import asyncio +import pygame + +# import pprint +import prettyprinter as pp + +pp.install_extras(["dataclasses"], warn_on_error=False) + +Symbol = str + +# The choices map nice user strings to the lookup map in orders.order(orderType) +# for dynamically returning an order instance based on string name... +ORDER_TYPE_Q = Q( + "Order Type", + choices=[ + Choice("Limit", "LMT"), + Choice("Adaptive Fast", "LMT + ADAPTIVE + FAST"), + Choice("Adaptive Slow", "LMT + ADAPTIVE + SLOW"), + Choice("Peg Primary", "REL"), + Choice("MidPrice", "MIDPRICE"), + Choice("Market", "MKT"), + Choice("Adaptive Fast Market", "MKT + ADAPTIVE + FAST"), + Choice("Adaptive Slow Market", "MKT + ADAPTIVE + SLOW"), + ], +) + + +def lookupKey(contract): + """Given a contract, return something we can use as a lookup key. + + Needs some tricks here because spreads don't have a bulit-in + one dimensional representation.""" + + # exclude COMBO/BAG orders from local symbol replacement because + # those show the underlying symbol as localSymbol and it doesn't + # look like a spread/bag/combo. + if contract.localSymbol and not contract.tradingClass == "COMB": + return contract.localSymbol.replace(" ", "") + + # else, if a regular symbol but DOESN'T have a .localSymbol (means + # we added the quote from a contract without first qualifying it, + # which works, it's just missing extra details) + if contract.symbol and not contract.comboLegs: + return contract.symbol + + # else, is spread so need to make something new... + return tuple( + x.tuple() + for x in sorted(contract.comboLegs, key=lambda x: (x.ratio, x.action, x.conId)) + ) + + +@dataclass +class IOp(mutil.dispatch.Op): + """Common base class for all operations. + + Just lets us have a single point for common settings across all ops.""" + + def __post_init__(self): + # for ease of use, populate state IB into our own instance + self.ib = self.state.ib + + +@dataclass +class IOpQQuote(IOp): + """Quick Quote: Run a temporary quote request then print results when volatility is populated.""" + + def argmap(self) -> list[DArg]: + return [DArg("*symbols")] + + async def run(self): + if not self.symbols: + logger.error("No symbols requested?") + return + + contracts = [contractForName(sym) for sym in self.symbols] + + await self.state.qualify(*contracts) + + if not all(c.conId for c in contracts): + logger.error("Not all contracts reported successful lookup!") + logger.error(contracts) + return + + # BROKEN IBKR WORKAROUND: + # For IV/HV calculations, IBKR seems to have a server-side background + # process to generate them on-demand, but if they don't exist + # when your quote is requested, the fields will never be + # populated. + # Requesting quotes outside of RTH often takes 5-30 seconds to + # deliver IV/HV calculations (if they get populated at all). + # So we have to REQUEST the data, hope IBKR spawns its + # background calculations, CANCEL the request, then RE-REQUEST + # the data and hopefully it is populated the second time. + # Also, the first cancel must be delayed enough to trigger their + # background calculations or else they still won't populate + # in the future. + # Also also, the second request must be soon after the first + # (but not *TOO* soon!) or else their background cache will + # clear their background-created cached value and the entire + # double-request cycle needs to start again. + totalTry = 0 + while True: + tickers = [] + logger.info( + "Requesting tickers for {}", + ", ".join([c.symbol for c in contracts]), + ) + for contract in contracts: + # request most up to date data available + self.ib.reqMarketDataType(2) + + # Request quotes with metadata fields populated + # (note: metadata is only populated using "live" endpoints, + # so we can't use the self-canceling "11 second snapshot" parameter) + tickers.append( + self.ib.reqMktData( + contract, + tickFieldsForContract(contract), + ) + ) + + # Loop over quote results until they have all been reported + success = False + for i in range(0, 3): + ivhv = [(t.impliedVolatility, t.histVolatility) for t in tickers] + + # if any iv/hv are still nan, don't stop yet. + if np.isnan(ivhv).any() and totalTry < 10: + logger.warning("Still missing fields...") + totalTry += 1 + else: + if totalTry >= 10: + logger.warning("Quote never finished. Final state:") + + # if all iv and hv are populated, stop! + success = True + + df = pd.DataFrame(tickers) + + # extract contract data from nested object pandas would otherwise + # just convert to a blob of json text. + contractframe = pd.DataFrame([t.contract for t in tickers]) + + if contractframe.empty: + logger.error("No result!") + continue + + contractframe = contractframe["symbol secType conId".split()] + + # NB: 'halted' statuses are: + # -1 Halted status not available. + # 0 Not halted. + # 1 General halt. regulatory reasons. + # 2 Volatility halt. + df = df[ + """bid bidSize + ask askSize + last lastSize + volume open high low close + halted shortableShares + histVolatility impliedVolatility""".split() + ] + + # attach inner name data to data rows since it's a nested field thing + # this 'concat' works because the row index ids match across the contracts + # and the regular ticks we extracted. + df = pd.concat([contractframe, df], axis=1) + + printFrame(df) + break + + await asyncio.sleep(2) + + if success: + break + + for contract in contracts: + self.ib.cancelMktData(contract) + + # try again... + await asyncio.sleep(0.333) + + # all done! + for contract in contracts: + self.ib.cancelMktData(contract) + + +@dataclass +class IOpEvict(IOp): + """Evict a position using MIDPRICE sell order.""" + + def argmap(self): + return [ + DArg("sym"), + DArg( + "qty", + int, + lambda x: x != 0 and x >= -1, + "qty is the exact quantity to evict (or -1 to evict entire position)", + ), + ] + + async def run(self): + contract, qty, price = self.contractForPosition( + self.sym, None if self.qty == -1 else self.qty + ) + await self.state.qualify(contract) + + # set price floor to 3% below current live price for + # the midprice order floor. + limit = price / 1.03 + + # TODO: fix to BUY back SHORT positions + # (is 'qty' returned as negative from contractForPosition for short positions??) + order = orders.IOrder("SELL", qty, limit).midprice() + logger.info("Ordering {} via {}", contract, order) + trade = self.ib.placeOrder(contract, order) + logger.info("Placed: {}", pp.pformat(trade)) + + +@dataclass +class IOpDepth(IOp): + def argmap(self): + return [ + DArg("sym"), + DArg( + "count", + int, + lambda x: 0 < x < 300, + "depth checking iterations should be more than zero and less than a lot", + ), + ] + + async def run(self): + (contract,) = await self.state.qualify(contractForName(self.sym)) + + self.depthState = {} + self.depthState[contract] = self.ib.reqMktDepth(contract, isSmartDepth=True) + + # now we read lists of ticker.domBids and ticker.domAsks for the depths + # (each having .price, .size, .marketMaker) + for i in range(0, self.count): + t = self.depthState[contract] + + # loop for up to a second until bids or asks are populated + for j in range(0, 100): + if not (t.domBids or t.domAsks): + await asyncio.sleep(0.01) + + if not (t.domBids or t.domAsks): + logger.warning( + "[{}] Depth not populated. Failing depth {}.", + contract.localSymbol, + i, + ) + + if t.domBids or t.domAsks: + if False: + fmt = { + "Bids": pd.DataFrame(t.domBids), + "Asks": pd.DataFrame(t.domAsks), + } + printFrame( + pd.concat(fmt, axis=1).fillna(""), + f"{contract.symbol} by Market", + ) + + # Also show grouped by price with sizes summed and markets joined + # These frames are a bit of a mess: + # - Create frame + # - Group frame by price so same prices use one row + # - Add all sizes for the same price, and concatenate marketMaker cols + # - 'convert_dtypes()' so any collapsed rows become None instead of NaN + # (aggregation sum of int + NaN = float, but we want int, so we use + # int + None = int to stop decimals from appearing in the size sums) + # - re-sort by price based on side + # - bids: high to low + # - asks: low to high + # - Re-index the frame by current sorted positions so the concat joins correctly. + # - 'drop=True' means don't add a new column with the previous index value + + # condition dataframe reorganization on the input list existing. + # for some smaller symbols, bids or asks may not get returned + # by the flaky ibkr depth APIs + if t.domBids: + fixedBids = ( + pd.DataFrame(t.domBids) + .groupby("price", as_index=False) + .agg({"size": sum, "marketMaker": list}) + .convert_dtypes() + .sort_values(by=["price"], ascending=False) + .reset_index(drop=True) + ) + else: + fixedBids = pd.DataFrame() + + if t.domAsks: + fixedAsks = ( + pd.DataFrame(t.domAsks) + .groupby("price", as_index=False) + .agg({"size": sum, "marketMaker": list}) + .convert_dtypes() + .sort_values(by=["price"], ascending=True) + .reset_index(drop=True) + ) + else: + fixedAsks = pd.DataFrame() + + fmtJoined = {"Bids": fixedBids, "Asks": fixedAsks} + + # Create an order book with high bids and low asks first. + # Note: due to the aggregations above, the bids and asks + # may have different row counts. Extra rows will be + # marked as by pandas (and we can't fill them + # as blank because the cols have been coerced to + # specific data types via 'convert_dtypes()') + printFrame( + pd.concat(fmtJoined, axis=1), + f"{contract.symbol} Grouped by Price", + ) + + # Note: the 't.domTicks' field is just the "update feed" + # which ib_insync merges into domBids/domAsks + # automatically, so we don't need to care about + # the values inside t.domTicks + + if i < self.count - 1: + await asyncio.sleep(3) + + self.ib.cancelMktDepth(contract, isSmartDepth=True) + del self.depthState[contract] + + +@dataclass +class IOpRID(IOp): + """Retrieve ib_insync request ID and server Next Request ID""" + + def argmap(self): + # rid has no args! + return [] + + async def run(self): + logger.info("CLI Request ID: {}", self.ib.client._reqIdSeq) + logger.info( + "Server Next Request ID: {} (see server log)", self.ib.client.reqIds(0) + ) + + +@dataclass +class IOpModifyOrder(IOp): + """Modify an existing order using interactive prompts.""" + + def argmap(self): + # No args, we just use interactive prompts for now + return [] + + async def run(self): + # "openTrades" include the contract, order, and order status. + # "openOrders" only includes the order objects with no contract or status. + ords = self.ib.openTrades() + # logger.info("current orderS: {}", pp.pformat(ords)) + promptOrder = [ + Q( + "Current Order", + choices=[ + Choice( + f"{o.order.action:<4} {o.order.totalQuantity:<6} {o.contract.localSymbol or o.contract.symbol:<21} {o.order.orderType} {o.order.tif} lmt:${fmtPrice(o.order.lmtPrice):<7} aux:${fmtPrice(o.order.auxPrice):<7}", + o, + ) + for o in sorted(ords, key=tradeOrderCmp) + ], + ), + Q("New Limit Price"), + Q("New Stop Price"), + Q("New Quantity"), + ] + + pord = await self.state.qask(promptOrder) + try: + trade = pord["Current Order"] + lmt = pord["New Limit Price"] + stop = pord["New Stop Price"] + qty = pord["New Quantity"] + + contract = trade.contract + ordr = trade.order + + if not (lmt or stop or qty): + # User didn't provide new data, so stop processing + return None + + if lmt: + ordr.lmtPrice = float(lmt) + + if stop: + ordr.auxPrice = float(stop) + + if qty: + ordr.totalQuantity = float(qty) + except: + return None + + trade = self.ib.placeOrder(contract, ordr) + logger.info("Updated: {}", pp.pformat(trade)) + + +@dataclass +class IOpLimitOrder(IOp): + def argmap(self): + # allow symbol on command line, optionally + return [] + + async def run(self): + promptSide = [ + Q( + "Side", + choices=[ + "Buy to Open", + "Sell to Close", + "Sell to Open", + "Buy to Close", + ], + ), + ] + + gotside = await self.state.qask(promptSide) + + try: + isClose = "to Close" in gotside["Side"] + isSell = "Sell" in gotside["Side"] + except: + # user canceled prompt, so skip + return None + + if isClose: + port = self.ib.portfolio() + # Choices have a custom format/title string, but the + # return value is the PortfolioItem object which has: + # .position for the entire quantity in the account + # .contract for the contract object to use with orders + + if isSell: + # if SELL, show only LONG positions + # TODO: exclude current orders already fully booked! + portchoices = [ + Choice(strFromPositionRow(p), p) + for p in sorted(port, key=portSort) + if p.position > 0 + ] + else: + # if BUY, show only SHORT positions + portchoices = [ + Choice(strFromPositionRow(p), p) + for p in sorted(port, key=portSort) + if p.position < 0 + ] + + promptPosition = [ + Q("Symbol", choices=portchoices), + Q("Price"), + Q("Quantity"), + ORDER_TYPE_Q, + ] + else: + promptPosition = [ + Q("Symbol", value=" ".join(self.args)), + Q("Price"), + Q("Quantity"), + ORDER_TYPE_Q, + ] + + got = await self.state.qask(promptPosition) + + if not got: + # user canceled at position request + return None + + # if user canceled the form, just prompt again + try: + sym = got["Symbol"] + qty = float(got["Quantity"]) + price = float(got["Price"]) + isLong = gotside["Side"].startswith("Buy") + orderType = got["Order Type"] + except: + # logger.exception("Failed to get field?") + logger.info("Order creation canceled by user") + return None + + # if order is To Close, then find symbol inside our active portfolio + if isClose: + portitems = self.ib.portfolio() + contract = sym.contract + qty = sym.position if (qty is None or qty == -1) else qty + + if contract is None: + logger.error("Symbol [{}] not found in portfolio for closing!", sym) + else: + contract = contractForName(sym) + + if contract is None: + logger.error("Not submitting order because contract can't be formatted!") + return None + + await self.state.qualify(contract) + + if not contract.conId: + logger.error("Not submitting order because contract not qualified!") + return None + + if isinstance(contract, Option): + # don't break RTH with options... + # TODO: check against extended late 4:15 ending options SPY / SPX / QQQ / etc? + outsideRth = False + else: + outsideRth = True + + order = orders.IOrder( + "BUY" if isLong else "SELL", qty, price, outsiderth=outsideRth + ).order(orderType) + + logger.info("Ordering {} via {}", contract, order) + trade = self.ib.placeOrder(contract, order) + logger.info("Placed: {}", pp.pformat(trade)) + + +@dataclass +class IOpCachedQuote(IOp): + """Return last cached value of a subscribed quote symbol.""" + + def argmap(self): + return [DArg("*symbols")] + + async def run(self): + for sym in self.symbols: + # if asked in "future format", drop the slash + # TODO: should the slash removal be inside currentQuote? + # TODO: should the slashes be part of the quote symbol name anyway? + if sym.startswith("/"): + sym = sym[1:] + + self.state.currentQuote(sym) + + +@dataclass +class IOpCancelOrders(IOp): + """Cancel waiting orders via order ids or interactive prompt.""" + + def argmap(self): + return [ + DArg( + "*orderids", + lambda xs: [int(x) for x in xs], + errmsg="Order IDs must be integers!", + ) + ] + + async def run(self): + # If order ids not provided via command, show a selectable list of all orders + if not self.orderids: + ords = [ + CB( + "Orders to Cancel", + choices=[ + Choice( + f"{o.order.action} {o.contract.localSymbol} {o.order.totalQuantity} ${o.order.lmtPrice:.2f} == ${o.order.totalQuantity * o.order.lmtPrice * (100 if o.contract.secType == 'OPT' else 1):,.2f}", + o.order, + ) + for o in sorted(self.ib.openTrades(), key=tradeOrderCmp) + ], + ) + ] + oooos = await self.state.qask(ords) + + if not oooos: + logger.info("Cancel canceled by user cancelling") + return + + oooo = oooos["Orders to Cancel"] + else: + # else, use order IDs given on command line to find existing orders to cancel + oooo = [] + for orderid in self.orderids: + # These aren't indexed in anyway, so just n^2 search, but the + # count is likely to be tiny overall. + # TODO: we could maintain a cache of active orders indexed by orderId + for o in self.ib.orders(): + if o.orderId == orderid: + oooo.append(o) + + if oooo: + for o in oooo: + logger.info("Canceling order {}", o) + self.ib.cancelOrder(o) + + +@dataclass +class IOpBalance(IOp): + """Return the currently cached account balance summary.""" + + def argmap(self): + return [] + + async def run(self): + ords = self.state.summary + logger.info("{}", pp.pformat(ords)) + + +@dataclass +class IOpPositions(IOp): + """Print datatable of all positions.""" + + def argmap(self): + return [DArg("*symbols")] + + def totalFrame(self, df, costPrice=False): + # Add new Total index row as column sum (axis=0 is column sum; axis=1 is row sum) + totalCols = [ + "position", + "marketValue", + "totalCost", + "unrealizedPNL", + "dailyPNL", + "%", + ] + + # For spreads, we want to sum the costs/prices since they + # are under the same order (hopefully). + if costPrice: + totalCols.extend(["averageCost", "marketPrice"]) + + df.loc["Total"] = df[totalCols].sum(axis=0) + t = df.loc["Total"] + df.loc["Total", "%"] = ( + (t.marketValue - t.totalCost) / ((t.marketValue + t.totalCost) / 2) + ) * 100 + + # Calculated weighted percentage ownership profit/loss... + df["w%"] = df["%"] * (abs(df.totalCost) / df.loc["Total", "totalCost"]) + df.loc["Total", "w%"] = df["w%"].sum() + + if not self.symbols: + # give actual price columns more detail for sub-penny prices + # but give aggregate columns just two decimal precision + detailCols = [ + "marketPrice", + "averageCost", + "marketValue", + "strike", + ] + simpleCols = [ + "%", + "w%", + "unrealizedPNL", + "dailyPNL", + "totalCost", + ] + + df.loc[:, detailCols] = df[detailCols].applymap(lambda x: fmtPrice(x)) + df.loc[:, simpleCols] = df[simpleCols].applymap(lambda x: f"{x:,.2f}") + + # show fractional shares only if they exist + defaultG = ["position"] + df.loc[:, defaultG] = df[defaultG].applymap(lambda x: f"{x:,.10g}") + + df = df.fillna("") + + # manually override the string-printed 'nan' from .applymap() of totalCols + # for columns we don't want summations of. + df.at["Total", "closeOrder"] = "" + + if not costPrice: + df.at["Total", "marketPrice"] = "" + df.at["Total", "averageCost"] = "" + + return df + + async def run(self): + ords = self.ib.portfolio() + # logger.info("port: {}", pp.pformat(ords)) + + backQuickRef = [] + populate = [] + for o in ords: # , key=lambda p: p.contract.localSymbol): + backQuickRef.append((o.contract.secType, o.contract.symbol, o.contract)) + + make = {} + + # 't' used for switching on OPT/WAR/STK/FUT types later too. + t = o.contract.secType + + make["type"] = t + make["sym"] = o.contract.symbol + + if self.symbols and make["sym"] not in self.symbols: + continue + + # logger.info("contract is: {}", o.contract) + if isinstance(o.contract, Warrant) or isinstance(o.contract, Option): + try: + make["date"] = pendulum.parse( + o.contract.lastTradeDateOrContractMonth + ).date() + except: + logger.error("Row didn't have a good date? {}", o) + pass + make["strike"] = o.contract.strike + make["PC"] = o.contract.right + make["exch"] = o.contract.primaryExchange[:3] + make["position"] = o.position + make["marketPrice"] = o.marketPrice + + close = self.state.orderPriceForContract(o.contract, o.position) + + # if it's a list of tuples, break them by newlines for display + if isinstance(close, list): + closingSide = " ".join([str(x) for x in close]) + else: + closingSide = close + + make["closeOrder"] = closingSide + make["marketValue"] = o.marketValue + make["totalCost"] = o.averageCost * o.position + make["unrealizedPNL"] = o.unrealizedPNL + try: + make["dailyPNL"] = self.state.pnlSingle[o.contract.conId].dailyPnL + except: + logger.warning("No PNL for: {}", pp.pformat(o)) + # spreads don't like having daily PNL? + pass + + if t == "FUT": + # multiple is 5 for micros and 10 for minis + mult = int(o.contract.multiplier) + make["averageCost"] = o.averageCost / mult + make["%"] = (o.marketPrice * mult - o.averageCost) / o.averageCost * 100 + elif t == "BAG": + logger.info("available: {}", o) + elif t == "OPT": + # For some reason, IBKR reports marketPrice + # as the contract price, but averageCost as + # the total cost per contract. shrug. + make["%"] = (o.marketPrice * 100 - o.averageCost) / o.averageCost * 100 + + # show average cost per share instead of per contract + # because the "marketPrice" live quote is the quote + # per share, not per contract. + make["averageCost"] = o.averageCost / 100 + else: + make["%"] = (o.marketPrice - o.averageCost) / o.averageCost * 100 + make["averageCost"] = o.averageCost + + # if short, our profit percentage is reversed + if o.position < 0: + make["%"] *= -1 + make["averageCost"] *= -1 + make["marketPrice"] *= -1 + + populate.append(make) + # positions() just returns symbol names, share count, and cost basis. + # portfolio() returns PnL details and current market prices/values + df = pd.DataFrame( + data=populate, + columns=[ + "type", + "sym", + "PC", + "date", + "strike", + "exch", + "position", + "averageCost", + "marketPrice", + "closeOrder", + "marketValue", + "totalCost", + "unrealizedPNL", + "dailyPNL", + "%", + ], + ) + + df.sort_values(by=["date", "sym"], ascending=True, inplace=True) + + # re-number DF according to the new sort order + df.reset_index(drop=True, inplace=True) + + allPositions = self.totalFrame(df.copy()) + printFrame(allPositions, "All Positions") + + # attempt to find spreads by locating options with the same symbol + symbolCounts = df.pivot_table(index=["type", "sym"], aggfunc="size") + + spreadSyms = set() + for (postype, sym), symCount in symbolCounts.items(): + if postype == "OPT" and symCount > 1: + spreadSyms.add(sym) + + # print individual frames for each spread since the summations + # will look better + for sym in spreadSyms: + spread = df[(df.type == "OPT") & (df.sym == sym)] + spread = self.totalFrame(spread.copy(), costPrice=True) + printFrame(spread, f"[{sym}] Potential Spread Identified") + + matchingContracts = [ + contract + for type, bqrsym, contract in backQuickRef + if type == "OPT" and bqrsym == sym + ] + + # transmit the size of the spread only if all are the same + # TODO: figure out how to do this for butterflies, etc + equality = 0 + if spread.loc["Total", "position"] == "0": # yeah, it's a string here + equality = spread.iloc[0]["position"] + + closeit = self.state.orderPriceForSpread(matchingContracts, equality) + logger.info("Potential Closing Side: {}", closeit) + + +@dataclass +class IOpOrders(IOp): + """Show all currently active orders.""" + + def argmap(self): + return [] + + async def run(self): + ords = self.ib.openTrades() + + # Note: we extract custom fields here because the default is + # returning Order, Contract, and OrderStatus objects + # which don't print nicely on their own. + populate = [] + + # Fields: https://interactivebrokers.github.io/tws-api/classIBApi_1_1Order.html + # Note: no sort here because we sort the dataframe before showing + for o in ords: + # don't print canceled/rejected/inactive orders + if o.log[-1].status == "Inactive": + continue + + make = {} + make["id"] = o.order.orderId + make["sym"] = o.contract.symbol + parseContractOptionFields(o.contract, make) + make["occ"] = ( + o.contract.localSymbol.replace(" ", "") + if len(o.contract.localSymbol) > 15 + else "" + ) + make["xchange"] = o.contract.exchange + make["action"] = o.order.action + make["orderType"] = o.order.orderType + make["qty"] = o.order.totalQuantity + make["lmt"] = o.order.lmtPrice + make["aux"] = o.order.auxPrice + make["trail"] = ( + f"{o.order.trailStopPrice:,.2f}" + if isset(o.order.trailStopPrice) + else None + ) + make["tif"] = o.order.tif + # make["oca"] = o.order.ocaGroup + # make["gat"] = o.order.goodAfterTime + # make["gtd"] = o.order.goodTillDate + # make["status"] = o.orderStatus.status + make["rem"] = o.orderStatus.remaining + make["filled"] = o.order.totalQuantity - o.orderStatus.remaining + make["4-8"] = o.order.outsideRth + if o.order.action == "SELL": + if o.contract.secType == "OPT": + make["lreturn"] = ( + int(o.order.totalQuantity) * float(o.order.lmtPrice) * 100 + ) + else: + make["lreturn"] = int(o.order.totalQuantity) * float( + o.order.lmtPrice + ) + elif o.order.action == "BUY": + if o.contract.secType == "OPT": + make["lcost"] = ( + int(o.orderStatus.remaining) * float(o.order.lmtPrice) * 100 + ) + elif o.contract.secType == "BAG": + # is spread, so we need to print more details than just one strike... + myLegs: list[str] = [] + + make["lcost"] = ( + int(o.order.totalQuantity) * float(o.order.lmtPrice) * 100 + ) + + for x in o.contract.comboLegs: + cachedName = self.state.conIdCache.get(x.conId) + + # if ID -> Name not in the cache, create it + if not cachedName: + await self.state.qualify(Contract(conId=x.conId)) + + # now the name will be in the cache! + lsym = self.state.conIdCache[x.conId].localSymbol + lsymsym, lsymrest = lsym.split() + myLegs.append( + ( + x.action[0], # 0 + x.ratio, # 1 + # Don't need symbol because row has symbol... + # lsymsym, + lsymrest[-9], # 2 + str(pendulum.parse("20" + lsymrest[:6]).date()), # 3 + round(int(lsymrest[-8:]) / 1000, 2), # 4 + lsym.replace(" ", ""), # 5 + ) + ) + + # if all P or C, make it top level + if all(l[2] == myLegs[0][2] for l in myLegs): + make["PC"] = myLegs[0][2] + + # if all same date, make it top level + if all(l[3] == myLegs[0][3] for l in myLegs): + make["date"] = myLegs[0][3] + + # if all same strike, make it top level + if all(l[4] == myLegs[0][4] for l in myLegs): + make["strike"] = myLegs[0][4] + + make["legs"] = myLegs + else: + make["lcost"] = int(o.order.totalQuantity) * float(o.order.lmtPrice) + + # Convert UTC timestamps to ET / Exchange Time + # (TradeLogEntry.time is already a python datetime object) + make["log"] = [ + ( + pendulum.instance(l.time).in_tz("US/Eastern"), + l.status, + l.message, + ) + for l in o.log + ] + + populate.append(make) + # fmt: off + df = pd.DataFrame( + data=populate, + columns=["id", "action", "sym", 'PC', 'date', 'strike', + "xchange", "orderType", + "qty", "filled", "rem", "lmt", "aux", "trail", "tif", + "4-8", "lreturn", "lcost", "occ", "legs", "log"], + ) + + # fmt: on + if df.empty: + logger.info("No orders!") + else: + df.sort_values(by=["date", "sym"], ascending=True, inplace=True) + + df = df.set_index("id") + fmtcols = ["lreturn", "lcost"] + df.loc["Total"] = df[fmtcols].sum(axis=0) + df = df.fillna("") + df.loc[:, fmtcols] = df[fmtcols].applymap( + lambda x: f"{x:,.2f}" if isinstance(x, float) else x + ) + + toint = ["qty", "filled", "rem"] + df[toint] = df[toint].applymap(lambda x: f"{x:,.0f}" if x else "") + df[["4-8"]] = df[["4-8"]].applymap(lambda x: True if x else "") + + printFrame(df) + + +@dataclass +class IOpSound(IOp): + """Stop or start the default order sound""" + + def argmap(self): + return [] + + async def run(self): + if pygame.mixer.music.get_busy(): + pygame.mixer.music.stop() + else: + pygame.mixer.music.play() + + +@dataclass +class IOpExecutions(IOp): + """Display all executions including commissions and PnL.""" + + def argmap(self): + return [] + + async def run(self): + # "Fills" has: + # - contract + # - execution (price at exchange) + # - commissionReport (commission and PNL) + # - time (UTC) + # .executions() is the same as just the 'execution' value in .fills() + fills = self.ib.fills() + # logger.info("Fills: {}", pp.pformat(fills)) + contracts = [] + executions = [] + commissions = [] + for f in fills: + contracts.append(f.contract) + executions.append(f.execution) + commissions.append(f.commissionReport) + + use = [] + for name, l in [ + ("Contracts", contracts), + ("Executions", executions), + ("Commissions", commissions), + ]: + df = pd.DataFrame(l) + if df.empty: + logger.info("No {}", name) + else: + use.append((name, df)) + + if use: + df = pd.concat({name: frame for name, frame in use}, axis=1) + + # Remove all-zero and all-empty columns and all-None... + df = df.loc[:, df.any(axis=0)] + + # Goodbye multiindex... + df.columns = df.columns.droplevel(0) + + # Remove duplicate columns... + df = df.loc[:, ~df.columns.duplicated()] + + # these may have been removed if no options exist, + # or these may not exist for buy-only transactions (PNL, etc). + for x in ["strike", "right", "date", "realizedPNL"]: + df[x] = 0 + + df["c_each"] = df.commission / df.shares + + df.loc["med"] = df[["c_each", "shares", "price", "avgPrice"]].median() + df.loc["mean"] = df[["c_each", "shares", "price", "avgPrice"]].mean() + df.loc["sum"] = df[ + ["shares", "price", "avgPrice", "commission", "realizedPNL"] + ].sum() + + needsPrices = "c_each shares price avgPrice commission realizedPNL".split() + df[needsPrices] = df[needsPrices].applymap(fmtPrice) + + df.fillna("", inplace=True) + + df.rename(columns={"lastTradeDateOrContractMonth": "date"}, inplace=True) + # ignoring: "execId" (long string for execution recall) and "permId" (???) + df = df[ + ( + """ secType conId symbol strike right date exchange localSymbol tradingClass time + side shares price orderId cumQty avgPrice + lastLiquidity commission c_each realizedPNL""".split() + ) + ] + + printFrame(df, "Execution Summary") + + +@dataclass +class IOpQuotesAdd(IOp): + """Add live quotes to display.""" + + def argmap(self): + return [DArg("*symbols")] + + async def run(self): + if not self.symbols: + return + + ors: list[buylang.OrderRequest] = [] + for sym in self.symbols: + # don't double subscribe + if sym.upper() in self.state.quoteState: + continue + + orderReq = self.state.ol.parse(sym) + ors.append(orderReq) # contractForName(sym)) + + # technically not necessary for quotes, but we want the contract + # to have the full '.localSymbol' designation for printing later. + # await self.state.qualify(*cs) + cs: list[Contract] = await asyncio.gather( + *[self.state.contractForOrderRequest(o) for o in ors] + ) + + for contract in cs: + if not contract: + continue + + # HACK because we can only use delayed on VXM + if contract.symbol == "VXM": + # delayed + self.ib.reqMarketDataType(3) + else: + # real time, but with last price if outside of hours. + self.ib.reqMarketDataType(2) + + tickFields = tickFieldsForContract(contract) + + # remove spaces from OCC-like symbols for key reference + symkey = lookupKey(contract) + + self.state.quoteState[symkey] = self.ib.reqMktData(contract, tickFields) + self.state.quoteContracts[symkey] = contract + + +@dataclass +class IOpQuotesAddFromOrderId(IOp): + """Add symbols for current orders to the quotes view.""" + + def argmap(self): + return [DArg("*orderIds", lambda xs: [int(x) for x in xs])] + + async def run(self): + trades = self.ib.openTrades() + + if not self.orderIds: + addTrades = trades + else: + addTrades = [] + for oid in self.orderIds: + useTrade = None + for t in trades: + if t.order.orderId == oid: + useTrade = t + break + + if not useTrade: + logger.error("No order found for id {}", oid) + continue + + addTrades.append(useTrade) + + for useTrade in addTrades: + self.ib.reqMarketDataType(2) + tickFields = tickFieldsForContract(useTrade.contract) + + # If this is a new session and we haven't previously cached the + # contract id -> name mappings, we need to look them all up now + # or else the next print of the quote toolbar will throw lots + # of missing key exceptions when trying to find names. + if useTrade.contract.comboLegs: + for x in useTrade.contract.comboLegs: + # if ID -> Name not in the cache, create it + if not x.conId in self.state.conIdCache: + await self.state.qualify(Contract(conId=x.conId)) + else: + if useTrade.contract.conId not in self.state.conIdCache: + await self.state.qualify(Contract(conId=useTrade.contract.conId)) + + symkey = lookupKey(useTrade.contract) + self.state.quoteState[symkey] = self.ib.reqMktData( + useTrade.contract, tickFields + ) + self.state.quoteContracts[symkey] = useTrade.contract + + +@dataclass +class IOpQuotesRemove(IOp): + """Remove live quotes from display.""" + + def argmap(self): + return [DArg("*symbols")] + + async def run(self): + for sym in self.symbols: + if len(sym) > 30: + # this is a combo request, so we need to evaluate, resolve, then key it + orderReq = self.state.ol.parse(sym) + contract = await self.state.contractForOrderRequest(orderReq) + else: + # else, just a regular one-symbol lookup + # logger.warning("QCs are: {}", pp.pformat(self.state.quoteContracts)) + contract = self.state.quoteContracts.get(sym) + + if contract: + try: + self.ib.cancelMktData(contract) + + symkey = lookupKey(contract) + del self.state.quoteContracts[symkey] + del self.state.quoteState[symkey] + except: + # user requested removal of non-subscribed quote + # (which is still okay) + logger.exception("no go?") + pass + + +@dataclass +class IOpSpreadOrder(IOp): + """Place a spread order described by using BuyLang/OrderLang""" + + def argmap(self): + return [DArg("*legdesc")] + + async def run(self): + promptPosition = [ + Q("Symbol", value=" ".join(self.legdesc)), + Q("Price"), + Q("Quantity"), + ORDER_TYPE_Q, + ] + + got = await self.state.qask(promptPosition) + + try: + req = got["Symbol"] + orderReq = self.state.ol.parse(req) + qty = int(got["Quantity"]) + price = float(got["Price"]) + orderType = got["Order Type"] + # It appears spreads with IBKR always have "BUY" order action, then the + # credit/debit is addressed by negative or positive prices. + # (i.e. you can't "short" a spread and I guess closing the spread is + # just an "inverse BUY" in their API's view) + order = orders.IOrder("BUY", qty, price, outsiderth=False).order(orderType) + except: + logger.warning("Order canceled due to incomplete fields") + return None + + bag = await self.state.bagForSpread(orderReq) + + trade = await self.ib.whatIfOrderAsync(bag, order) + logger.info("Impact: {}", pp.pformat(trade)) + + trade = self.ib.placeOrder(bag, order) + logger.info("Trade: {}", pp.pformat(trade)) + + # self.ib.reqMarketDataType(2) + # self.state.quoteState["THEBAG"] = self.ib.reqMktData(bag) + # self.state.quoteContracts["THEBAG"] = bag + + +@dataclass +class IOpOptionChain(IOp): + """Print option chains for symbol""" + + def argmap(self): + return [DArg("symbol")] + + async def run(self): + contractExact = contractForName(self.symbol) + + # If full option symbol, get all strikes for the date of the symbol + if isinstance(contractExact, (Option, FuturesOption)): + contractExact.strike = 0.00 + + print(contractExact) + chainsAll = await self.ib.reqSecDefOptParamsAsync( + contractExact.symbol, + contractExact.exchange, + "FUT" if self.symbol.startswith("/") else "STK", + contractExact.conId, + ) + + chainsExact = await self.ib.reqContractDetailsAsync(contractExact) + + # TODO: cache this result! + strikes = sorted([d.contract.strike for d in chainsExact]) + + logger.info("Strikes: {}", strikes) + df = pd.DataFrame(chainsAll) + printFrame(df) + + +# TODO: potentially split these out into indepdent plugin files? +OP_MAP = { + "Live Market Quotes": { + "qquote": IOpQQuote, + "quote": IOpCachedQuote, + "depth": IOpDepth, + "add": IOpQuotesAdd, + "oadd": IOpQuotesAddFromOrderId, + "remove": IOpQuotesRemove, + "chains": IOpOptionChain, + }, + "Order Management": { + "limit": IOpLimitOrder, + "spread": IOpSpreadOrder, + "modify": IOpModifyOrder, + "evict": IOpEvict, + "cancel": IOpCancelOrders, + }, + "Portfolio": { + "balance": IOpBalance, + "positions": IOpPositions, + "orders": IOpOrders, + "executions": IOpExecutions, + }, + "Connection": { + "rid": IOpRID, + }, + "Utilities": { + "fast": None, + "rcheck": None, + "future": None, + "bars": None, + "buy": None, + "try": None, + "tryf": None, + "snd": IOpSound, + }, +} + + +# Simple copy template for new commands +@dataclass +class IOp_(IOp): + def argmap(self): + return [DArg()] + + async def run(self): + ... + + +@dataclass +class Dispatch: + def __post_init__(self): + self.d = mutil.dispatch.Dispatch(OP_MAP) + + def runop(self, *args, **kwargs) -> Coroutine: + return self.d.runop(*args, **kwargs) diff --git a/icli/orders.py b/icli/orders.py new file mode 100644 index 0000000..2dbaac3 --- /dev/null +++ b/icli/orders.py @@ -0,0 +1,356 @@ +""" Common order types with reusable parameter configurations.""" +from ib_insync import ( + Order, + LimitOrder, + TagValue, + MarketOrder, + Contract, + OrderCondition, + StopLimitOrder, +) +from dataclasses import dataclass, field + +from typing import * + +import enum +from enum import Enum, auto + +# Note: all functions should have params in the same order! + +# Order field details: +# https://interactivebrokers.github.io/tws-api/classIBApi_1_1Order.html + + +@dataclass +class IOrder: + """A wrapper class to help organize the common order logic we want to reuse. + + This looks a bit weird because we are basically duplicating most of the Order + fields ourself and populating them before passing them back to Order, but this + allows us to basically generate one abstract Order request then pull out more + specific concrete Order types with conditions/algos as needed via encapsulating + the meta-order logic inside methods generating the actual final Order object. + Individual field detail meanings are at: + https://interactivebrokers.github.io/tws-api/classIBApi_1_1Order.html + """ + + action: Literal["BUY", "SELL"] + + qty: int + + # basic limit price + lmt: float = 0.00 + + # aux holds anything not a limit price and not a trailing percentage: + # - stop price for stop / stop limita / stop with protection + # - trailing amounts for trailing orders (instead of .trailingPercent) + # - touch price on MIT + # - offset for pegs (treated as (bid + aux) for sell and (ask - off) for buys) + # - trigger price for LIT (Same as touch for MIT, when the "IT" becomes marketable) + aux: float = 0.00 + + # Note: IBKR gives a warning (but not a hard error) if assigning GTC to options. + tif: Literal["GTC", "IOC", "FOK", "OPG", "GTD", "DAY"] = "GTC" + + # format for these is just YYYYMMDD HH:MM:SS and assume exchange timezone i guess + goodtildate: str = "" + goodaftertime: str = "" + + # trailing things. + # Note: trailpct is a floating percentage of the current price trigger + # instead of using a static 'aux' amount of points for the trail. + trailstop: float = 0.00 + trailpct: float = 0.00 + + # for relative orders, offset (if any) can be a percent instead of + # a direct amount defined in 'aux' + percentoffset: float = 0.00 + + # order type is created per-returned type in a request method + + # multi-group creation + ocagroup: Optional[str] = None + + # 1: cancel remaining orders + # 2: reduce remaining orders (no overfill, only on market if not active) + # 3: reduce remaining orders (potential overfill, all on market) + ocatype: Literal[1, 2, 3] = 1 + transmit: bool = True + parentId: Optional[int] = None + + # stock sweeps + sweeptofill: bool = False # stocks and warrants only + + # Trigger methods: + # 0 - The default value. + # The "double bid/ask" for OTC stocks and US options. + # All other orders will used the "last" function. + # 1 - "double bid/ask", stop orders triggered on two consecutive bid/ask quotes. + # 2 - "last", stop orders are triggered based on the last price. + # 3 double last function. + # 4 bid/ask function. + # 7 last or bid/ask function. + # 8 mid-point function. + trigger: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8] = 3 + triggerprice: float = 0.00 + outsiderth: bool = True + override: bool = False + + # Volatility Orders + stockrefprice: float = 0.00 + volatility: float = 0.00 + voltype: Literal[1, 2] = 2 + continuousupdate: bool = True + refpricetype: Literal[1, 2] = 2 + + # algos + algostrategy: Literal[ + "ArrivalPx", + "DarkIce", + "PctVol", + "Twap", + "Vwap", + "Adaptive", + "MinImpact", + "BalanceImpactRisk", + "PctVolTm", + "PctVolSz", + "AD", + "ClosePx", + "ArrivalPx", + ] = "Adaptive" + algoparams: Optional[list[TagValue]] = None + + # preview + whatif: bool = False + + # conditions.. + conditions: Optional[list[OrderCondition]] = None + + def order(self, orderType: str) -> Order: + """Return a specific Order object by name.""" + omap = { + # Basic + "LMT": self.limit, + "MKT": self.market, + "REL": self.pegPrimary, + # One Algo + "MIDPRICE": self.midprice, + # Common Adaptives + "LMT + ADAPTIVE + SLOW": self.adaptiveSlowLmt, + "LMT + ADAPTIVE + FAST": self.adaptiveFastLmt, + "MKT + ADAPTIVE + SLOW": self.adaptiveSlowMkt, + "MKT + ADAPTIVE + FAST": self.adaptiveFastMkt, + # Multi-Leg Orders + "REL + MKT": self.comboPrimaryPegMkt, + "REL + LMT": self.comboPrimaryPegLmt, + "LMT + MKT": self.comboLmtMkt, + } + + return omap[orderType]() + + def commonArgs(self, override: dict[str, Any] = None) -> dict[str, Any]: + common = dict( + tif=self.tif, + goodTillDate=self.goodtildate, + goodAfterTime=self.goodaftertime, + outsideRth=self.outsiderth, + whatIf=self.whatif, + ) + + if override: + common.update(override) + + return common + + def midprice(self) -> Order: + # Floating MIDPRICE with no caps: + # https://interactivebrokers.github.io/tws-api/ibalgos.html#midprice + # Also note: midprice is ONLY for RTH usage + return Order( + action=self.action, + totalQuantity=self.qty, + lmtPrice=self.lmt, # API docs say "optional" but API errors out unless price given. shrug. + orderType="MIDPRICE", + **self.commonArgs(dict(tif="DAY")), + ) + + def market(self) -> Order: + return Order( + action=self.action, + totalQuantity=self.qty, + orderType="MKT", + **self.commonArgs(), + ) + + def adaptiveFastLmt(self) -> LimitOrder: + # Note: adaptive can't be GTC! + return LimitOrder( + action=self.action, + totalQuantity=self.qty, + lmtPrice=self.lmt, + algoStrategy="Adaptive", + algoParams=[TagValue("adaptivePriority", "Urgent")], + **self.commonArgs(dict(tif="DAY")), + ) + + def adaptiveSlowLmt(self) -> LimitOrder: + # Note: adaptive can't be GTC! + return LimitOrder( + action=self.action, + totalQuantity=self.qty, + lmtPrice=self.lmt, + algoStrategy="Adaptive", + algoParams=[TagValue("adaptivePriority", "Patient")], + **self.commonArgs(dict(tif="DAY")), + ) + + def adaptiveFastMkt(self) -> MarketOrder: + # Note: adaptive can't be GTC! + return MarketOrder( + action=self.action, + totalQuantity=self.qty, + algoStrategy="Adaptive", + algoParams=[TagValue("adaptivePriority", "Urgent")], + **self.commonArgs(dict(tif="DAY")), + ) + + def adaptiveSlowMkt(self) -> MarketOrder: + # Note: adaptive can't be GTC! + return MarketOrder( + action=self.action, + totalQuantity=self.qty, + algoStrategy="Adaptive", + algoParams=[TagValue("adaptivePriority", "Patient")], + **self.commonArgs(dict(tif="DAY")), + ) + + def stopLimit(self) -> StopLimitOrder: + return StopLimitOrder( + self.action, + self.qty, + self.lmt, # Limit price under the stop... + self.aux, # Stop trigger price... + **self.commonArgs(), + ) + + def trailingStopLimit(self) -> Order: + if self.aux and self.trailpct: + raise Exception("Can't specify both Aux and Trailing Percent!") + + # Exclusive, can't have both: + # auxPrice=self.aux, # TRAILING AMOUNT IN DOLLARS + # trailingPercent=self.trailingPercent # TRAILING AMOUNT IN PERCENT + if self.aux: + whichTrail = dict(auxPrice=self.aux) + else: + whichTrail = dict(trailingPercent=self.trailpct) + return Order( + action=self.action, + totalQuantity=self.qty, + lmtPriceOffset=self.lmtPriceOffset, # HOW FAR DOWN TO START THE LIMIT ± AGAINST CURRENT PRICE (- sell, + buy) + trailStopPrice=self.trailstop, # IF NO UP MOVEMENT, WHEN TO TRIGGER ORDER <-- THIS IS WHAT "TRAILS" + orderType="TRAIL LIMIT", + **whichTrail, + **self.commonArgs(), + ) + + def limit(self) -> LimitOrder: + return LimitOrder( + self.action, + self.qty, + self.lmt, + **self.commonArgs(), + ) + + def pegPrimary(self) -> Order: + return Order( + action=self.action, + totalQuantity=self.qty, + lmtPrice=self.lmt, # API docs say "optional" but API errors out unless price given. shrug. + auxPrice=self.aux, # aggressive opposite offset of peg, can be 0 to float freely + orderType="REL", + **self.commonArgs(), + ) + + def comboPrimaryPegMkt(self) -> Order: + """Submitted as REL, but when one leg fills, other leg is eaten at market.""" + # Explained at: + # https://www.interactivebrokers.com/en/software/tws/usersguidebook/ordertypes/relative___market.htm + return Order( + action=self.action, + totalQuantity=self.qty, + triggerPrice=self.triggerprice, + orderType="REL + MKT", + **self.commonArgs(), + ) + + def comboPrimaryPegLmt(self) -> Order: + """Submitted as REL, but when REL triggers, other is limit? So can't be guaranteed? This isn't described anywhere.""" + # Explained at: + return Order( + action=self.action, + totalQuantity=self.qty, + triggerPrice=self.aux, + orderType="REL + LMT", + **self.commonArgs(), + ) + + def comboLmtMkt(self) -> Order: + """Submitted as LMT, but other leg goes market when limit hits.""" + # https://www.interactivebrokers.com/en/software/tws/usersguidebook/ordertypes/limit___market.htm + return Order( + action=self.action, + totalQuantity=self.qty, + triggerPrice=self.lmt, + orderType="LMT + MKT", + **self.commonArgs(), + ) + + +@enum.unique +class CLIOrderType(Enum): + """IBKR Order Types + + Extracted from the IBKR Java client enum OrderType from: + IBJts/source/JavaClient/com/ib/client/OrderType.java""" + + MKT = "MKT" + LMT = "LMT" + STP = "STP" + STP_LMT = "STP LMT" + REL = "REL" + TRAIL = "TRAIL" + BOX_TOP = "BOX TOP" + FIX_PEGGED = "FIX PEGGED" + LIT = "LIT" + LMT_PLUS_MKT = "LMT + MKT" + LOC = "LOC" + MIT = "MIT" + MKT_PRT = "MKT PRT" + MOC = "MOC" + MTL = "MTL" + PASSV_REL = "PASSV REL" + PEG_BENCH = "PEG BENCH" + PEG_MID = "PEG MID" + PEG_MKT = "PEG MKT" + PEG_PRIM = "PEG PRIM" + PEG_STK = "PEG STK" + REL_PLUS_LMT = "REL + LMT" + REL_PLUS_MKT = "REL + MKT" + SNAP_MID = "SNAP MID" + SNAP_MKT = "SNAP MKT" + SNAP_PRIM = "SNAP PRIM" + STP_PRT = "STP PRT" + TRAIL_LIMIT = "TRAIL LIMIT" + TRAIL_LIT = "TRAIL LIT" + TRAIL_LMT_PLUS_MKT = "TRAIL LMT + MKT" + TRAIL_MIT = "TRAIL MIT" + TRAIL_REL_PLUS_MKT = "TRAIL REL + MKT" + VOL = "VOL" + VWAP = "VWAP" + QUOTE = "QUOTE" + PEG_PRIM_VOL = "PPV" + PEG_MID_VOL = "PDV" + PEG_MKT_VOL = "PMV" + PEG_SRF_VOL = "PSV" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1aa606 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[tool.poetry] +name = "icli" +version = "0.7.6" +description = "ibkr cli et al" +authors = ["Matt Stancliff "] +license = "Apache-2.0" + +[tool.poetry.dependencies] +python = ">=3.9,<3.10" + +# IB API wrapper with better usability than the IBKR-provided libs +ib-insync = "^0.9.66" + +# nice printing and data manipulation +pandas = "^1.2.3" + +# easy time access +pendulum = "^2.1.2" + +# running / logging +prompt-toolkit = "^3.0.17" +loguru = "^0.5.3" + +# for multi-step cli prompts +questionary = "^1.9.0" +tableprint = "^0.9.1" + +# for ANSI console color gradients +seaborn = "^0.11.1" + +# for audio output +pygame = "^2.0.1" + +# for configs +python-dotenv = "^0.18.0" + +# for showing nice things +prettyprinter = "^0.18.0" +setproctitle = "^1.2.2" + +# saving things and converting IBKR HTML news into readable text +diskcache = "^5.2.1" +beautifulsoup4 = "^4.9.3" + +# our API helpers and wrappers +tradeapis = { git = "https://github.com/mattsta/tradeapis.git", tag="1.0.3" } +# tradeapis = { path = "../clients/", develop = true } + +[tool.poetry.dev-dependencies] +data-science-types = "^0.2.23" +mypy = "^0.812" + +[tool.poetry.scripts] +icli = "icli.__main__:runit" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"