Initial dynamic option chain UI draft

There's still a ton to polish (and some bugs to fix) but this is a first
working draft of a real-time option chain!

Insights and todos:
- `kivy` widgets need to be cached and reused (eg. rows, cells, etc.)
  for speed since it seems creating new ones constantly is quite taxing
  on the CPU
- the chain will tear down and re-setup the option data feed stream each
  time a different contract expiry button set is clicked
- there's still some weird bug with row highlighting where it seems rows
  added from a new expiry set (which weren't previously rendered) aren't
  being highlighted reliably
kivy_mainline_and_py3.8
Tyler Goodlet 2018-12-13 13:04:05 -05:00
parent 1d1be9dd77
commit 9e4786e62f
2 changed files with 333 additions and 142 deletions

View File

@ -304,6 +304,9 @@ class Row(GridLayout, HoverBehavior):
cell.key = key cell.key = key
self._cell_widgets[key] = cell self._cell_widgets[key] = cell
def iter_cells(self):
return self._cell_widgets.items()
def get_cell(self, key): def get_cell(self, key):
return self._cell_widgets.get(key) return self._cell_widgets.get(key)
@ -356,7 +359,7 @@ class Row(GridLayout, HoverBehavior):
"""Highlight layout on enter. """Highlight layout on enter.
""" """
log.debug( log.debug(
f"Entered row {type(self)} through {self.border_point}") f"Entered row {self} through {self.border_point}")
# don't highlight header row # don't highlight header row
if getattr(self, 'is_header', None): if getattr(self, 'is_header', None):
self.hovered = False self.hovered = False
@ -365,7 +368,7 @@ class Row(GridLayout, HoverBehavior):
"""Un-highlight layout on exit. """Un-highlight layout on exit.
""" """
log.debug( log.debug(
f"Left row {type(self)} through {self.border_point}") f"Left row {self} through {self.border_point}")
class TickerTable(GridLayout): class TickerTable(GridLayout):
@ -424,7 +427,7 @@ class TickerTable(GridLayout):
symbol name. Most naive algo possible for the moment. symbol name. Most naive algo possible for the moment.
""" """
for symbol, row in self.symbols2rows.items(): for symbol, row in self.symbols2rows.items():
if patt in symbol: if patt in symbol:
yield symbol, row yield symbol, row
def get_row(self, symbol: str) -> Row: def get_row(self, symbol: str) -> Row:
@ -442,10 +445,12 @@ async def update_quotes(
widgets: dict, widgets: dict,
agen: AsyncGeneratorType, agen: AsyncGeneratorType,
symbol_data: dict, symbol_data: dict,
first_quotes: dict first_quotes: dict,
task_status: trio._core._run._TaskStatus = trio.TASK_STATUS_IGNORED,
): ):
"""Process live quotes by updating ticker rows. """Process live quotes by updating ticker rows.
""" """
log.debug("Initializing UI update loop")
table = widgets['table'] table = widgets['table']
flash_keys = {'low', 'high'} flash_keys = {'low', 'high'}
@ -521,9 +526,8 @@ async def update_quotes(
color_row(row, record, {}) color_row(row, record, {})
cache[sym] = row cache[sym] = row
# render all rows once up front log.debug("Finished initializing update loop")
table.render_rows(cache) task_status.started()
# real-time cell update loop # real-time cell update loop
async for quotes in agen: # new quotes data only async for quotes in agen: # new quotes data only
for symbol, quote in quotes.items(): for symbol, quote in quotes.items():
@ -538,6 +542,7 @@ async def update_quotes(
log.debug("Waiting on quotes") log.debug("Waiting on quotes")
log.warn("Data feed connection dropped") log.warn("Data feed connection dropped")
# XXX: if we're cancelled this should never get called
nursery.cancel_scope.cancel() nursery.cancel_scope.cancel()
@ -617,7 +622,8 @@ async def _async_main(
for ticker_record in first_quotes: for ticker_record in first_quotes:
table.append_row( table.append_row(
ticker_record['symbol'], ticker_record['symbol'],
Row(ticker_record, headers=('symbol',), bidasks=bidasks, table=table) Row(ticker_record, headers=('symbol',),
bidasks=bidasks, table=table)
) )
# associate the col headers row with the ticker table even though # associate the col headers row with the ticker table even though

View File

@ -5,8 +5,11 @@ Launch with ``piker options <symbol>``.
""" """
import types import types
from functools import partial from functools import partial
from typing import Dict, List
# import typing
import trio import trio
from async_generator import asynccontextmanager
import tractor import tractor
from kivy.uix.boxlayout import BoxLayout from kivy.uix.boxlayout import BoxLayout
from kivy.lang import Builder from kivy.lang import Builder
@ -27,129 +30,328 @@ async def modify_symbol(symbol):
pass pass
class ExpiryButton(HeaderCell):
def on_press(self, value=None):
log.info(f"Clicked {self}")
class StrikeCell(Cell): class StrikeCell(Cell):
"""Strike cell""" """Strike cell"""
_no_display = ['symbol', 'contract_type', 'strike', 'time', 'open'] _no_display = ['symbol', 'contract_type', 'strike', 'time', 'open']
_strike_row_cache = {}
_strike_cell_cache = {}
class StrikeRow(BoxLayout): class StrikeRow(BoxLayout):
"""A 'row' composed of two ``Row``s sandwiching a """A 'row' composed of two ``Row``s sandwiching a
``StrikeCell`. ``StrikeCell`.
""" """
_row_cache = {}
def __init__(self, strike, **kwargs): def __init__(self, strike, **kwargs):
super().__init__(orientation='horizontal', **kwargs) super().__init__(orientation='horizontal', **kwargs)
self.strike = strike self.strike = strike
# store 2 rows: 1 for call, 1 for put # store 2 rows: 1 for call, 1 for put
self._sub_rows = {} self._sub_rows = {}
self.table = None self._widgets_added = False
def append_sub_row( def append_sub_row(
self, self,
record: dict, record: dict,
displayable: dict,
bidasks=None, bidasks=None,
headers=(), headers=(),
table=None, table=None,
**kwargs, **kwargs,
) -> None: ) -> None:
if self.is_populated(): # if self.is_populated():
raise TypeError(f"{self} can only append two sub-rows?") # raise TypeError(f"{self} can only append two sub-rows?")
# the 'contract_type' determines whether this # the 'contract_type' determines whether this
# is a put or call row # is a put or call row
contract_type = record['contract_type'] contract_type = record['contract_type']
# reverse order of call side cells # We want to only create a few ``Row`` widgets as possible to
if contract_type == 'call': # speed up rendering; we cache sub rows after creation.
record = dict(list(reversed(list(record.items())))) row = self._row_cache.get((self.strike, contract_type))
if not row:
# reverse order of call side cells
if contract_type == 'call':
record = dict(list(reversed(list(record.items()))))
row = Row(
record,
bidasks=bidasks,
headers=headers,
table=table,
no_cell=_no_display,
**kwargs
)
self._row_cache[(self.strike, contract_type)] = row
else:
# must update the internal cells
row.update(record, displayable)
row = Row(
record,
bidasks=bidasks,
headers=headers,
table=table,
no_cell=_no_display,
**kwargs
)
# reassign widget for when rendered in the update loop # reassign widget for when rendered in the update loop
row.widget = self row.widget = self
self._sub_rows[contract_type] = row self._sub_rows[contract_type] = row
if self.is_populated():
if self.is_populated() and not self._widgets_added:
# calls on the left # calls on the left
self.add_widget(self._sub_rows['call']) self.add_widget(self._sub_rows['call'])
# strikes in the middle strike_cell = _strike_cell_cache.setdefault(
self.add_widget( self.strike, StrikeCell(
StrikeCell(
key=self.strike, key=self.strike,
text=str(self.strike), text=str(self.strike),
is_header=True, is_header=True,
# make centre strike cell nice and small # make centre strike cell nice and small
size_hint=(1/8., 1), size_hint=(1/10., 1),
) )
) )
# strikes in the middle
self.add_widget(strike_cell)
# puts on the right # puts on the right
self.add_widget(self._sub_rows['put']) self.add_widget(self._sub_rows['put'])
self._widgets_added = True
def is_populated(self): def is_populated(self):
"""Bool determing if both a put and call subrow have beed appended. """Bool determing if both a put and call subrow have beed appended.
""" """
return len(self._sub_rows) == 2 return len(self._sub_rows) == 2
def has_widgets(self):
return self._widgets_added
def update(self, record, displayable): def update(self, record, displayable):
self._sub_rows[record['contract_type']].update( self._sub_rows[record['contract_type']].update(
record, displayable) record, displayable)
async def _async_main( class ExpiryButton(HeaderCell):
symbol: str, def on_press(self, value=None):
log.info(f"Clicked {self}")
if self.chain.sub[1] == self.key:
log.info(f"Clicked {self} is already selected")
return
log.info(f"Subscribing for {self.chain.sub}")
self.chain.start_displaying(self.chain.sub[0], self.key)
class DataFeed(object):
"""Data feed client for streaming symbol data from a remote
broker data source.
"""
def __init__(self, portal, brokermod):
self.portal = portal
self.brokermod = brokermod
self.sub = None
self.quote_gen = None
async def open_stream(self, symbols, rate=3, test=None):
if self.quote_gen is not None and symbols != self.sub:
log.info(f"Stopping existing subscription for {self.sub}")
await self.quote_gen.aclose()
self.sub = symbols
if test:
# stream from a local test file
quote_gen = await self.portal.run(
"piker.brokers.data", 'stream_from_file',
filename=test
)
else:
# start live streaming from broker daemon
quote_gen = await self.portal.run(
"piker.brokers.data",
'start_quote_stream',
broker=self.brokermod.name,
symbols=symbols,
feed_type='option',
rate=rate,
)
# get first quotes response
log.debug(f"Waiting on first quote for {symbols}...")
quotes = await quote_gen.__anext__()
self.quote_gen = quote_gen
self.first_quotes = quotes
# self.records = records
# self.displayables = displayables
return quote_gen, quotes
class OptionChain(object):
"""A real-time options chain UI.
"""
def __init__(
self,
symbol: str,
expiry: str,
widgets: dict,
bidasks: Dict[str, List[str]],
feed: DataFeed,
rate: int = 1,
):
self.sub = (symbol, expiry)
self.widgets = widgets
self.bidasks = bidasks
self._strikes2rows = {}
self._nursery = None
self.feed = feed
self._update_cs = None
# TODO: this should be moved down to the data feed layer
# right now it's only needed for the UI uupdate loop to cancel itself
self._first_quotes = None
@asynccontextmanager
async def open_scope(self):
"""Open an internal resource and update task scope required
to allow for dynamic real-time operation.
"""
# assign us to each expiry button
for key, button in (
self.widgets['expiry_buttons']._cell_widgets.items()
):
button.chain = self
async with trio.open_nursery() as n:
self._nursery = n
n.start_soon(self.start_updating)
yield self
self._nursery = None
await self.feed.quote_gen.aclose()
def clear(self):
"""Clear the strike rows from the internal table.
"""
table = self.widgets['table']
table.clear_widgets()
for strike in self._strikes2rows.copy():
self._strikes2rows.pop(strike)
def render_rows(self, records, displayables):
"""Render all strike rows in the internal table.
"""
log.debug("Rendering rows")
table = self.widgets['table']
for record, display in zip(
sorted(records, key=lambda q: q['strike']),
displayables
):
strike = record['strike']
strike_row = _strike_row_cache.setdefault(
strike, StrikeRow(strike))
strike_row.append_sub_row(
record,
display,
bidasks=self.bidasks,
table=table,
)
if strike_row.is_populated():
# We must fill out the the table's symbol2rows manually
# using each contracts "symbol" so that the quote updater
# task can look up the right row to update easily
# See update_quotes() and ``Row`` internals for details.
for contract_type, row in strike_row._sub_rows.items():
symbol = row._last_record['symbol']
table.symbols2rows[symbol] = row
if strike not in self._strikes2rows:
# readding widgets is an error
table.add_widget(strike_row)
self._strikes2rows[strike] = strike_row
log.debug("Finished rendering rows!")
async def start_feed(
self,
symbol: str,
expiry: str,
# max QT rate per API customer is approx 4 rps
# and usually 3 rps is allocated to the stock monitor
rate: int = 1,
test: str = None
):
if self.feed.sub != self.sub:
return await self.feed.open_stream([(symbol, expiry)], rate=rate)
else:
feed = self.feed
return feed.quote_gen, feed.first_quotes
async def start_updating(self):
if self._update_cs:
self._update_cs.cancel()
await trio.sleep(0)
self.clear()
if self._nursery is None:
raise RuntimeError(
"You must call await `start()` first!")
n = self._nursery
log.debug(f"Waiting on first_quotes for {self.sub}")
quote_gen, first_quotes = await self.start_feed(*self.sub)
log.debug(f"Got first_quotes for {self.sub}")
# redraw the UI
records, displayables = zip(*[
self.feed.brokermod.format_option_quote(quote, {})
for quote in first_quotes.values()
])
self.render_rows(records, displayables)
with trio.open_cancel_scope() as cs:
self._update_cs = cs
# start quote update loop
await n.start(
partial(
update_quotes,
self._nursery,
self.feed.brokermod.format_option_quote,
self.widgets,
quote_gen,
symbol_data={},
first_quotes=first_quotes,
)
)
def start_displaying(self, symbol, expiry):
self.sub = (symbol, expiry)
self._nursery.start_soon(self.start_updating)
async def new_chain_ui(
portal: tractor._portal.Portal, portal: tractor._portal.Portal,
symbol: str,
expiry: str,
contracts,
brokermod: types.ModuleType, brokermod: types.ModuleType,
rate: int = 4, nursery: trio._core._run.Nursery,
test: bool = False rate: int = 1,
) -> None: ) -> None:
'''Launch kivy app + all other related tasks. """Create and return a new option chain UI.
"""
This is started with cli cmd `piker options`. widgets = {}
'''
# retreive all contracts
all_contracts = await contracts(brokermod, symbol)
first_expiry = next(iter(all_contracts)).expiry
if test:
# stream from a local test file
quote_gen = await portal.run(
"piker.brokers.data", 'stream_from_file',
filename=test
)
else:
# start live streaming from broker daemon
quote_gen = await portal.run(
"piker.brokers.data",
'start_quote_stream',
broker=brokermod.name,
symbols=[(symbol, first_expiry)],
feed_type='option',
)
# get first quotes response
log.debug("Waiting on first quote...")
quotes = await quote_gen.__anext__()
records, displayables = zip(*[
brokermod.format_option_quote(quote, {})
for quote in quotes.values()
])
# define bid-ask "stacked" cells # define bid-ask "stacked" cells
# (TODO: needs some rethinking and renaming for sure) # (TODO: needs some rethinking and renaming for sure)
bidasks = brokermod._option_bidasks bidasks = brokermod._option_bidasks
# build out UI feed = DataFeed(portal, brokermod)
chain = OptionChain(
symbol,
expiry,
widgets,
bidasks,
feed,
rate=rate,
)
quote_gen, first_quotes = await chain.start_feed(symbol, expiry)
records, displayables = zip(*[
brokermod.format_option_quote(quote, {})
for quote in first_quotes.values()
])
# build out root UI
title = f"option chain: {symbol}\t(press ? for help)" title = f"option chain: {symbol}\t(press ? for help)"
Window.set_title(title) Window.set_title(title)
@ -163,7 +365,7 @@ async def _async_main(
# TODO: figure out how to compact these buttons # TODO: figure out how to compact these buttons
expiries = { expiries = {
key.expiry: key.expiry[:key.expiry.find('T')] key.expiry: key.expiry[:key.expiry.find('T')]
for key in all_contracts for key in contracts
} }
expiry_buttons = Row( expiry_buttons = Row(
record=expiries, record=expiries,
@ -182,94 +384,77 @@ async def _async_main(
header_record['contract_type'] = 'put' header_record['contract_type'] = 'put'
header_row.append_sub_row( header_row.append_sub_row(
header_record, header_record,
headers=headers,
bidasks=bidasks,
is_header=True,
size_hint=(1, None),
)
header_record['contract_type'] = 'call'
header_row.append_sub_row(
header_record, header_record,
headers=headers, headers=headers,
bidasks=bidasks, bidasks=bidasks,
is_header=True, is_header=True,
size_hint=(1, None), size_hint=(1, None),
)
header_record['contract_type'] = 'call'
header_row.append_sub_row(
header_record,
header_record,
headers=headers,
bidasks=bidasks,
is_header=True,
size_hint=(1, None),
) )
container.add_widget(header_row) container.add_widget(header_row)
table = TickerTable( table = TickerTable(
sort_key='strike', sort_key='strike',
cols=1, cols=1,
size_hint=(1, None), size_hint=(1, None),
) )
header_row.table = table header_row.table = table
table.bind(minimum_height=table.setter('height'))
pager = PagerView(
container=container,
contained=table,
nursery=nursery
)
container.add_widget(pager)
widgets.update({
'root': container,
'container': container,
'table': table,
'expiry_buttons': expiry_buttons,
'pager': pager,
})
return chain
strike_rows = {}
for record, display in zip(sorted(
records,
key=lambda q: q['strike'],
), displayables):
strike = record['strike']
strike_row = strike_rows.setdefault(
strike, StrikeRow(strike))
strike_row.append_sub_row(
record,
bidasks=bidasks,
table=table,
)
if strike_row.is_populated():
# We must fill out the the table's symbol2rows manually
# using each contracts "symbol" so that the quote updater
# task can look up the right row to update easily
# See update_quotes() and ``Row`` for details.
for contract_type, row in strike_row._sub_rows.items():
table.symbols2rows[row._last_record['symbol']] = row
table.append_row(symbol, strike_row) async def _async_main(
symbol: str,
portal: tractor._portal.Portal,
brokermod: types.ModuleType,
rate: int = 1,
test: bool = False
) -> None:
'''Launch kivy app + all other related tasks.
This is started with cli cmd `piker options`.
'''
# retreive all contracts just because we need a default when the
# UI starts up
all_contracts = await contracts(brokermod, symbol)
# start streaming soonest contract by default
first_expiry = next(iter(all_contracts)).expiry
async with trio.open_nursery() as nursery: async with trio.open_nursery() as nursery:
# set up a pager view for large ticker lists # set up a pager view for large ticker lists
table.bind(minimum_height=table.setter('height')) chain = await new_chain_ui(
pager = PagerView( portal,
container=container, symbol,
contained=table, first_expiry,
nursery=nursery all_contracts,
brokermod,
nursery,
rate=rate,
) )
container.add_widget(pager) async with chain.open_scope():
widgets = { try:
'root': container, # Trio-kivy entry point.
'container': container, await async_runTouchApp(chain.widgets['root']) # run kivy
'table': table, finally:
'expiry_buttons': expiry_buttons, # cancel GUI update task
'pager': pager, nursery.cancel_scope.cancel()
}
nursery.start_soon(
partial(
update_quotes,
nursery,
brokermod.format_option_quote,
widgets,
quote_gen,
symbol_data={},
first_quotes=quotes,
)
)
try:
# Trio-kivy entry point.
await async_runTouchApp(widgets['root']) # run kivy
finally:
await quote_gen.aclose() # cancel aysnc gen call
# un-subscribe from symbols stream (cancel if brokerd
# was already torn down - say by SIGINT)
with trio.move_on_after(0.2):
await portal.run(
"piker.brokers.data", 'modify_quote_stream',
broker=brokermod.name,
feed_type='option',
symbols=[]
)
# cancel GUI update task
nursery.cancel_scope.cancel()