diff --git a/README.rst b/README.rst index 16ee0400..244b5a4f 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ piker ----- Trading gear for hackers. -|pypi| |travis| |versions| |license| |docs| +|travis| ``piker`` is an attempt at a pro-grade, broker agnostic, next-gen FOSS toolset for real-time trading and financial analysis. @@ -10,16 +10,25 @@ trading and financial analysis. It tries to use as much cutting edge tech as possible including (but not limited to): - Python 3.7+ -- ``trio`` -- ``tractor`` +- trio_ +- tractor_ +- kivy_ .. |travis| image:: https://img.shields.io/travis/pikers/piker/master.svg :target: https://travis-ci.org/pikers/piker +.. _trio: https://github.com/python-trio/trio +.. _tractor: https://github.com/goodboy/tractor +.. _kivy: https://kivy.org + +Also, we're always open to new framework suggestions and ideas! + +Building the best looking, most reliable, keyboard friendly trading platform is the dream. +Feel free to pipe in with your ideas and quiffs. Install ******* -``piker`` is currently under heavy alpha development and as such should +``piker`` is currently under heavy pre-alpha development and as such should be cloned from this repo and hacked on directly. A couple bleeding edge components are being used atm pertaining to @@ -33,7 +42,20 @@ For a development install:: pipenv install --dev -e . pipenv shell -To start the real-time index ETF watchlist with the `questrade` backend:: + +Broker Support +************** +For live data feeds the only fully functional broker at the moment is Questrade_. +Eventual support is in the works for `IB`, `TD Ameritrade` and `IEX`. +If you want your broker supported and they have an API let us know. + +.. _Questrade: https://www.questrade.com/api/documentation + + +Play with some UIs +****************** + +To start the real-time index monitor with the `questrade` backend:: piker -l info monitor indexes diff --git a/piker/brokers/__init__.py b/piker/brokers/__init__.py index ae14799c..53597752 100644 --- a/piker/brokers/__init__.py +++ b/piker/brokers/__init__.py @@ -18,7 +18,7 @@ def get_brokermod(brokername: str) -> ModuleType: """Return the imported broker module by name. """ module = import_module('.' + brokername, 'piker.brokers') - # we only allows monkeys because it's for internal keying + # we only allow monkeying because it's for internal keying module.name = module.__name__.split('.')[-1] return module diff --git a/piker/cli.py b/piker/cli.py index 21b51611..dcdf3f06 100644 --- a/piker/cli.py +++ b/piker/cli.py @@ -385,7 +385,7 @@ def optschain(config, symbol, date, tl, rate, test): """ # global opts loglevel = config['loglevel'] - brokermod = config['brokermod'] + brokername = config['broker'] from .ui.option_chain import _async_main @@ -395,9 +395,10 @@ def optschain(config, symbol, date, tl, rate, test): ) as portal: # run app "main" await _async_main( - symbol, portal, - brokermod, + symbol, + brokername, rate=rate, + loglevel=loglevel, test=test, ) diff --git a/piker/ui/monitor.py b/piker/ui/monitor.py index 7404a750..78f25bc1 100644 --- a/piker/ui/monitor.py +++ b/piker/ui/monitor.py @@ -7,6 +7,7 @@ Launch with ``piker monitor ``. """ from types import ModuleType, AsyncGeneratorType from typing import List, Callable +from functools import partial import trio import tractor @@ -163,6 +164,7 @@ async def _async_main( portal: tractor._portal.Portal, tickers: List[str], brokermod: ModuleType, + loglevel: str = 'info', rate: int = 3, test: bool = False ) -> None: @@ -212,6 +214,7 @@ async def _async_main( Row(ticker_record, headers=('symbol',), bidasks=bidasks, table=table) ) + table.last_clicked_row = next(iter(table.symbols2rows.values())) # associate the col headers row with the ticker table even though # they're technically wrapped separately in containing BoxLayout @@ -226,11 +229,28 @@ async def _async_main( table.bind(minimum_height=table.setter('height')) ss = tractor.current_actor().statespace + + async def spawn_opts_chain(): + """Spawn an options chain UI in a new subactor. + """ + from .option_chain import _async_main + + async with tractor.open_nursery() as tn: + await tn.run_in_actor( + 'optschain', + _async_main, + symbol=table.last_clicked_row._last_record['symbol'], + brokername=brokermod.name, + # loglevel=tractor.log.get_loglevel(), + ) + async with trio.open_nursery() as nursery: pager = PagerView( container=box, contained=table, - nursery=nursery + nursery=nursery, + # spawn an option chain on 'o' keybinding + kbctls={('o',): spawn_opts_chain}, ) box.add_widget(pager) diff --git a/piker/ui/option_chain.py b/piker/ui/option_chain.py index 4ebe9dac..e2421103 100644 --- a/piker/ui/option_chain.py +++ b/piker/ui/option_chain.py @@ -15,8 +15,9 @@ from kivy.app import async_runTouchApp from kivy.core.window import Window from kivy.uix.label import Label -from ..log import get_logger +from ..log import get_logger, get_console_log from ..brokers.data import DataFeed +from ..brokers import get_brokermod from .pager import PagerView from .tabular import Row, HeaderCell, Cell, TickerTable @@ -408,7 +409,7 @@ class OptionChain(object): self.render_rows(records, displayables) - with trio.open_cancel_scope() as cs: + with trio.CancelScope() as cs: self._update_cs = cs await self._nursery.start( partial( @@ -479,28 +480,36 @@ async def new_chain_ui( async def _async_main( symbol: str, - portal: tractor._portal.Portal, - brokermod: types.ModuleType, + brokername: str, rate: int = 1, + loglevel: str = 'info', test: bool = False ) -> None: '''Launch kivy app + all other related tasks. This is started with cli cmd `piker options`. ''' + if loglevel is not None: + get_console_log(loglevel) + + brokermod = get_brokermod(brokername) + async with trio.open_nursery() as nursery: - # set up a pager view for large ticker lists - chain = await new_chain_ui( - portal, - symbol, - brokermod, - rate=rate, - ) - async with chain.open_rt_display(nursery, symbol): - try: - await async_runTouchApp(chain.widgets['root']) - finally: - if chain._quote_gen: - await chain._quote_gen.aclose() - # cancel GUI update task - nursery.cancel_scope.cancel() + # get a portal to the data feed daemon + async with tractor.wait_for_actor('brokerd') as portal: + + # set up a pager view for large ticker lists + chain = await new_chain_ui( + portal, + symbol, + brokermod, + rate=rate, + ) + async with chain.open_rt_display(nursery, symbol): + try: + await async_runTouchApp(chain.widgets['root']) + finally: + if chain._quote_gen: + await chain._quote_gen.aclose() + # cancel GUI update task + nursery.cancel_scope.cancel() diff --git a/piker/ui/pager.py b/piker/ui/pager.py index d70175d8..7900f60a 100644 --- a/piker/ui/pager.py +++ b/piker/ui/pager.py @@ -5,6 +5,7 @@ import inspect from functools import partial from kivy.core.window import Window +from kivy.uix.widget import Widget from kivy.uix.textinput import TextInput from kivy.uix.scrollview import ScrollView @@ -12,7 +13,12 @@ from ..log import get_logger log = get_logger('keyboard') -async def handle_input(widget, patts2funcs: dict, patt_len_limit=3): +async def handle_input( + nursery, + widget, + patts2funcs: dict, + patt_len_limit=3 +) -> None: """Handle keyboard input. For each character pattern-tuple in ``patts2funcs`` invoke the @@ -31,10 +37,13 @@ async def handle_input(widget, patts2funcs: dict, patt_len_limit=3): while True: async for kb, keycode, text, modifiers in keyq: - log.debug( - f"Keyboard input received:\n" - f"key {keycode}\ntext {text}\nmodifiers {modifiers}" - ) + log.debug(f""" + kb: {kb} + keycode: {keycode} + text: {text} + modifiers: {modifiers} + patts2funcs: {patts2funcs} + """) code, key = keycode if modifiers and key in modifiers: continue @@ -52,8 +61,8 @@ async def handle_input(widget, patts2funcs: dict, patt_len_limit=3): # stop kb queue to avoid duplicate input processing keyq.stop() - log.debug(f'invoking kb coro func {func}') - await func() + log.debug(f'spawning task for kb func {func}') + nursery.start_soon(func) last_patt = [] break # trigger loop restart @@ -61,6 +70,7 @@ async def handle_input(widget, patts2funcs: dict, patt_len_limit=3): log.debug(f'invoking kb func {func}') func() last_patt = [] + break if len(last_patt) > patt_len_limit: last_patt = [] @@ -75,26 +85,41 @@ async def handle_input(widget, patts2funcs: dict, patt_len_limit=3): class SearchBar(TextInput): - def __init__(self, kbctls: dict, container: 'Widget', pager: 'PagerView', - searcher, **kwargs): + def __init__( + self, + container: Widget, + pager: 'PagerView', + searcher, + **kwargs + ): super(SearchBar, self).__init__( multiline=False, hint_text='Ticker Search', - cursor_blink=False, **kwargs ) + self.cursor_blink = False self.foreground_color = self.hint_text_color # actually readable - self.kbctls = kbctls self._container = container self._pager = pager self._searcher = searcher # indicate to ``handle_input`` that search is activated on '/' - self.kbctls.update({ + self._pager.kbctls.update({ ('/',): self.handle_input }) + + self.kbctls = { + ('ctrl-c',): self.undisplay, + } + self._sugg_template = ' '*4 + '{} matches: ' self._matched = [] + def undisplay(self): + "Stop displaying this text widget" + self.dispatch('on_text_validate') # same as pressing + if self.text_validate_unfocus: + self.focus = False + def suggest(self, matches): self.suggestion_text = '' suffix = self._sugg_template.format(len(matches) or "No") @@ -138,14 +163,31 @@ class SearchBar(TextInput): self._pager.scroll_to(widget) async def handle_input(self): + # TODO: wrap this in a cntx mng + old_ctls = self._pager.kbctls.copy() + self._pager.kbctls.clear() + # makes a copy + self._pager.kbctls.update(self.kbctls) + self._container.add_widget(self) # display it self.focus = True # focus immediately (doesn't work from __init__) + + # select any existing text making the widget ready + # to accept new input right away + if self.text: + self.select_all() + # wait for to close search bar await self.async_bind('on_text_validate').__aiter__().__anext__() log.debug(f"Seach text is {self.text}") log.debug("Closing search bar") self._container.remove_widget(self) # stop displaying + + # restore old keyboard bindings + self._pager.kbctls.clear() + self._pager.kbctls.update(old_ctls) + return self.text @@ -167,10 +209,9 @@ class PagerView(ScrollView): # add contained child widget (can only be one) self._contained = contained self.add_widget(contained) - self.search = SearchBar( - self.kbctls, container, self, searcher=contained) + self.search = SearchBar(container, self, searcher=contained) # spawn kb handler task - nursery.start_soon(handle_input, self, self.kbctls) + nursery.start_soon(handle_input, nursery, self, self.kbctls) def move_y(self, val): '''Scroll in the y direction [0, 1]. diff --git a/piker/ui/tabular.py b/piker/ui/tabular.py index bb307a91..d1219b8e 100644 --- a/piker/ui/tabular.py +++ b/piker/ui/tabular.py @@ -383,6 +383,7 @@ class Row(HoverBehavior, GridLayout): def on_press(self, value=None): log.info(f"Pressed row for {self._last_record['symbol']}") if self.table and not self.is_header: + self.table.last_clicked_row = self for sendchan in self.table._click_queues: sendchan.send_nowait(self._last_record['symbol']) @@ -396,6 +397,7 @@ class TickerTable(GridLayout): self.sort_key = sort_key # for tracking last clicked column header cell self.last_clicked_col_cell = None + self.last_clicked_row = None self._auto_sort = auto_sort self._symbols2index = {} self._sorted = []