From 214c6223286f83e2eef5eae66f6de44fd88fcf1b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 6 Sep 2021 22:05:42 -0400 Subject: [PATCH] Move allocator components to clearing sub-pkg --- piker/clearing/_allocate.py | 301 ++++++++++++++++++++++++++++++++++++ piker/ui/_position.py | 280 +-------------------------------- piker/ui/order_mode.py | 22 +-- 3 files changed, 313 insertions(+), 290 deletions(-) create mode 100644 piker/clearing/_allocate.py diff --git a/piker/clearing/_allocate.py b/piker/clearing/_allocate.py new file mode 100644 index 00000000..d283c58f --- /dev/null +++ b/piker/clearing/_allocate.py @@ -0,0 +1,301 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for piker0) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' +Position allocation logic and protocols. + +''' +from enum import Enum +from typing import Optional + +from bidict import bidict +from pydantic import BaseModel, validator + +from ..data._source import Symbol +from ._messages import BrokerdPosition, Status + + +class Position(BaseModel): + '''Basic pp (personal position) model with attached fills history. + + This type should be IPC wire ready? + + ''' + symbol: Symbol + + # last size and avg entry price + size: float + avg_price: float # TODO: contextual pricing + + # ordered record of known constituent trade messages + fills: list[Status] = [] + + def update_from_msg( + self, + msg: BrokerdPosition, + + ) -> None: + + # XXX: better place to do this? + symbol = self.symbol + + lot_size_digits = symbol.lot_size_digits + avg_price, size = ( + round(msg['avg_price'], ndigits=symbol.tick_size_digits), + round(msg['size'], ndigits=lot_size_digits), + ) + + self.avg_price = avg_price + self.size = size + + +_size_units = bidict({ + 'currency': '$ size', + 'units': '# units', + # TODO: but we'll need a `.get_accounts()` or something + # 'percent_of_port': '% of port', +}) +SizeUnit = Enum( + 'SizeUnit', + _size_units, +) + + +class Allocator(BaseModel): + + class Config: + validate_assignment = True + copy_on_model_validation = False + arbitrary_types_allowed = True + + # required to get the account validator lookup working? + extra = 'allow' + # underscore_attrs_are_private = False + + symbol: Symbol + + account: Optional[str] = 'paper' + _accounts: bidict[str, Optional[str]] + + @validator('account', pre=True) + def set_account(cls, v, values): + if v: + return values['_accounts'][v] + + size_unit: SizeUnit = 'currency' + _size_units: dict[str, Optional[str]] = _size_units + + @validator('size_unit') + def lookup_key(cls, v): + # apply the corresponding enum key for the text "description" value + return v.name + + # TODO: if we ever want ot support non-uniform entry-slot-proportion + # "sizes" + # disti_weight: str = 'uniform' + + units_limit: float + currency_limit: float + slots: int + + def step_sizes( + self, + ) -> (float, float): + '''Return the units size for each unit type as a tuple. + + ''' + slots = self.slots + return ( + self.units_limit / slots, + self.currency_limit / slots, + ) + + def limit(self) -> float: + if self.size_unit == 'currency': + return self.currency_limit + else: + return self.units_limit + + def next_order_info( + self, + + startup_pp: Position, + live_pp: Position, + price: float, + action: str, + + ) -> dict: + '''Generate order request info for the "next" submittable order + depending on position / order entry config. + + ''' + sym = self.symbol + ld = sym.lot_size_digits + + size_unit = self.size_unit + live_size = live_pp.size + abs_live_size = abs(live_size) + abs_startup_size = abs(startup_pp.size) + + u_per_slot, currency_per_slot = self.step_sizes() + + if size_unit == 'units': + slot_size = u_per_slot + l_sub_pp = self.units_limit - abs_live_size + + elif size_unit == 'currency': + live_cost_basis = abs_live_size * live_pp.avg_price + slot_size = currency_per_slot / price + l_sub_pp = (self.currency_limit - live_cost_basis) / price + + # an entry (adding-to or starting a pp) + if ( + action == 'buy' and live_size > 0 or + action == 'sell' and live_size < 0 or + live_size == 0 + ): + + order_size = min(slot_size, l_sub_pp) + + # an exit (removing-from or going to net-zero pp) + else: + # when exiting a pp we always try to slot the position + # in the instrument's units, since doing so in a derived + # size measure (eg. currency value, percent of port) would + # result in a mis-mapping of slots sizes in unit terms + # (i.e. it would take *more* slots to exit at a profit and + # *less* slots to exit at a loss). + pp_size = max(abs_startup_size, abs_live_size) + slotted_pp = pp_size / self.slots + + if size_unit == 'currency': + # compute the "projected" limit's worth of units at the + # current pp (weighted) price: + slot_size = currency_per_slot / live_pp.avg_price + + else: + slot_size = u_per_slot + + # if our position is greater then our limit setting + # we'll want to use slot sizes which are larger then what + # the limit would normally determine + order_size = max(slotted_pp, slot_size) + + if ( + abs_live_size < slot_size or + + # NOTE: front/back "loading" heurstic: + # if the remaining pp is in between 0-1.5x a slot's + # worth, dump the whole position in this last exit + # therefore conducting so called "back loading" but + # **without** going past a net-zero pp. if the pp is + # > 1.5x a slot size, then front load: exit a slot's and + # expect net-zero to be acquired on the final exit. + slot_size < pp_size < round((1.5*slot_size), ndigits=ld) + ): + order_size = abs_live_size + + slots_used = 1.0 # the default uniform policy + if order_size < slot_size: + # compute a fractional slots size to display + slots_used = self.slots_used( + Position(symbol=sym, size=order_size, avg_price=price) + ) + + return { + 'size': abs(round(order_size, ndigits=ld)), + 'size_digits': ld, + + # TODO: incorporate multipliers for relevant derivatives + 'fiat_size': round(order_size * price, ndigits=2), + 'slots_used': slots_used, + } + + def slots_used( + self, + pp: Position, + + ) -> float: + '''Calc and return the number of slots used by this ``Position``. + + ''' + abs_pp_size = abs(pp.size) + + if self.size_unit == 'currency': + # live_currency_size = size or (abs_pp_size * pp.avg_price) + live_currency_size = abs_pp_size * pp.avg_price + prop = live_currency_size / self.currency_limit + + else: + # return (size or abs_pp_size) / alloc.units_limit + prop = abs_pp_size / self.units_limit + + # TODO: REALLY need a way to show partial slots.. + # for now we round at the midway point between slots + return round(prop * self.slots) + + +def mk_allocator( + + alloc: Allocator, + startup_pp: Position, + +) -> (float, Allocator): + + asset_type = alloc.symbol.type_key + + # load and retreive user settings for default allocations + # ``config.toml`` + slots = 4 + currency_limit = 5e3 + + alloc.slots = slots + alloc.currency_limit = currency_limit + + # default entry sizing + if asset_type in ('stock', 'crypto', 'forex'): + + alloc.size_unit = '$ size' + + elif asset_type in ('future', 'option', 'futures_option'): + + # since it's harder to know how currency "applies" in this case + # given leverage properties + alloc.size_unit = '# units' + + # set units limit to slots size thus making make the next + # entry step 1.0 + alloc.units_limit = slots + + # if the current position is already greater then the limit + # settings, increase the limit to the current position + if alloc.size_unit == 'currency': + startup_size = startup_pp.size * startup_pp.avg_price + + if startup_size > alloc.currency_limit: + alloc.currency_limit = round(startup_size, ndigits=2) + + limit_text = alloc.currency_limit + + else: + startup_size = startup_pp.size + + if startup_size > alloc.units_limit: + alloc.units_limit = startup_size + + limit_text = alloc.units_limit + + return limit_text, alloc diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 8cff1b95..7c48e940 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -20,15 +20,12 @@ Position info and display """ from __future__ import annotations from dataclasses import dataclass -from enum import Enum from functools import partial from math import floor from typing import Optional -from bidict import bidict from pyqtgraph import functions as fn -from pydantic import BaseModel, validator from ._annotate import LevelMarker from ._anchors import ( @@ -36,8 +33,7 @@ from ._anchors import ( gpath_pin, ) from ..calc import humanize -from ..clearing._messages import BrokerdPosition, Status -from ..data._source import Symbol +from ..clearing._allocate import Allocator, Position from ._label import Label from ._lines import LevelLine, order_line from ._style import _font @@ -48,280 +44,6 @@ from ..clearing._messages import Order log = get_logger(__name__) -class Position(BaseModel): - '''Basic pp (personal position) model with attached fills history. - - This type should be IPC wire ready? - - ''' - symbol: Symbol - - # last size and avg entry price - size: float - avg_price: float # TODO: contextual pricing - - # ordered record of known constituent trade messages - fills: list[Status] = [] - - def update_from_msg( - self, - msg: BrokerdPosition, - - ) -> None: - - # XXX: better place to do this? - symbol = self.symbol - - lot_size_digits = symbol.lot_size_digits - avg_price, size = ( - round(msg['avg_price'], ndigits=symbol.tick_size_digits), - round(msg['size'], ndigits=lot_size_digits), - ) - - self.avg_price = avg_price - self.size = size - - -_size_units = bidict({ - 'currency': '$ size', - 'units': '# units', - # TODO: but we'll need a `.get_accounts()` or something - # 'percent_of_port': '% of port', -}) -SizeUnit = Enum( - 'SizeUnit', - _size_units, -) - - -class Allocator(BaseModel): - - class Config: - validate_assignment = True - copy_on_model_validation = False - arbitrary_types_allowed = True - - # required to get the account validator lookup working? - extra = 'allow' - # underscore_attrs_are_private = False - - symbol: Symbol - - account: Optional[str] = 'paper' - _accounts: bidict[str, Optional[str]] - - @validator('account', pre=True) - def set_account(cls, v, values): - if v: - return values['_accounts'][v] - - size_unit: SizeUnit = 'currency' - _size_units: dict[str, Optional[str]] = _size_units - - @validator('size_unit') - def lookup_key(cls, v): - # apply the corresponding enum key for the text "description" value - return v.name - - # TODO: if we ever want ot support non-uniform entry-slot-proportion - # "sizes" - # disti_weight: str = 'uniform' - - units_limit: float - currency_limit: float - slots: int - - def step_sizes( - self, - ) -> (float, float): - '''Return the units size for each unit type as a tuple. - - ''' - slots = self.slots - return ( - self.units_limit / slots, - self.currency_limit / slots, - ) - - def limit(self) -> float: - if self.size_unit == 'currency': - return self.currency_limit - else: - return self.units_limit - - def next_order_info( - self, - - startup_pp: Position, - live_pp: Position, - price: float, - action: str, - - ) -> dict: - '''Generate order request info for the "next" submittable order - depending on position / order entry config. - - ''' - sym = self.symbol - ld = sym.lot_size_digits - - size_unit = self.size_unit - live_size = live_pp.size - abs_live_size = abs(live_size) - abs_startup_size = abs(startup_pp.size) - - u_per_slot, currency_per_slot = self.step_sizes() - - if size_unit == 'units': - slot_size = u_per_slot - l_sub_pp = self.units_limit - abs_live_size - - elif size_unit == 'currency': - live_cost_basis = abs_live_size * live_pp.avg_price - slot_size = currency_per_slot / price - l_sub_pp = (self.currency_limit - live_cost_basis) / price - - # an entry (adding-to or starting a pp) - if ( - action == 'buy' and live_size > 0 or - action == 'sell' and live_size < 0 or - live_size == 0 - ): - - order_size = min(slot_size, l_sub_pp) - - # an exit (removing-from or going to net-zero pp) - else: - # when exiting a pp we always try to slot the position - # in the instrument's units, since doing so in a derived - # size measure (eg. currency value, percent of port) would - # result in a mis-mapping of slots sizes in unit terms - # (i.e. it would take *more* slots to exit at a profit and - # *less* slots to exit at a loss). - pp_size = max(abs_startup_size, abs_live_size) - slotted_pp = pp_size / self.slots - - if size_unit == 'currency': - # compute the "projected" limit's worth of units at the - # current pp (weighted) price: - slot_size = currency_per_slot / live_pp.avg_price - - else: - slot_size = u_per_slot - - # if our position is greater then our limit setting - # we'll want to use slot sizes which are larger then what - # the limit would normally determine - order_size = max(slotted_pp, slot_size) - - if ( - abs_live_size < slot_size or - - # NOTE: front/back "loading" heurstic: - # if the remaining pp is in between 0-1.5x a slot's - # worth, dump the whole position in this last exit - # therefore conducting so called "back loading" but - # **without** going past a net-zero pp. if the pp is - # > 1.5x a slot size, then front load: exit a slot's and - # expect net-zero to be acquired on the final exit. - slot_size < pp_size < round((1.5*slot_size), ndigits=ld) - ): - order_size = abs_live_size - - slots_used = 1.0 # the default uniform policy - if order_size < slot_size: - # compute a fractional slots size to display - slots_used = self.slots_used( - Position(symbol=sym, size=order_size, avg_price=price) - ) - - return { - 'size': abs(round(order_size, ndigits=ld)), - 'size_digits': ld, - - # TODO: incorporate multipliers for relevant derivatives - 'fiat_size': round(order_size * price, ndigits=2), - 'slots_used': slots_used, - } - - def slots_used( - self, - pp: Position, - - ) -> float: - '''Calc and return the number of slots used by this ``Position``. - - ''' - abs_pp_size = abs(pp.size) - - if self.size_unit == 'currency': - # live_currency_size = size or (abs_pp_size * pp.avg_price) - live_currency_size = abs_pp_size * pp.avg_price - prop = live_currency_size / self.currency_limit - - else: - # return (size or abs_pp_size) / alloc.units_limit - prop = abs_pp_size / self.units_limit - - # TODO: REALLY need a way to show partial slots.. - # for now we round at the midway point between slots - return round(prop * self.slots) - - -def mk_allocator( - - alloc: Allocator, - startup_pp: Position, - config_section: dict = {}, - -) -> (float, Allocator): - - asset_type = alloc.symbol.type_key - - # load and retreive user settings for default allocations - # ``config.toml`` - slots = 4 - currency_limit = 5e3 - - alloc.slots = slots - alloc.currency_limit = currency_limit - - # default entry sizing - if asset_type in ('stock', 'crypto', 'forex'): - - alloc.size_unit = '$ size' - - elif asset_type in ('future', 'option', 'futures_option'): - - # since it's harder to know how currency "applies" in this case - # given leverage properties - alloc.size_unit = '# units' - - # set units limit to slots size thus making make the next - # entry step 1.0 - alloc.units_limit = slots - - # if the current position is already greater then the limit - # settings, increase the limit to the current position - if alloc.size_unit == 'currency': - startup_size = startup_pp.size * startup_pp.avg_price - - if startup_size > alloc.currency_limit: - alloc.currency_limit = round(startup_size, ndigits=2) - - limit_text = alloc.currency_limit - - else: - startup_size = startup_pp.size - - if startup_size > alloc.units_limit: - alloc.units_limit = startup_size - - limit_text = alloc.units_limit - - return limit_text, alloc - - @dataclass class SettingsPane: '''Composite set of widgets plus an allocator model for configuring diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index c3e86d7b..6ef9228f 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -35,6 +35,12 @@ import trio from .. import config from ..calc import pnl from ..clearing._client import open_ems, OrderBook +from ..clearing._allocate import ( + Allocator, + mk_allocator, + Position, + _size_units, +) from ..data._source import Symbol from ..data._normalize import iterticks from ..data.feed import Feed @@ -42,12 +48,8 @@ from ..log import get_logger from ._editors import LineEditor, ArrowEditor from ._lines import order_line, LevelLine from ._position import ( - Position, - Allocator, - mk_allocator, PositionTracker, SettingsPane, - _size_units, ) from ._window import MultiStatus from ..clearing._messages import Order @@ -545,6 +547,7 @@ async def open_order_mode( # allocator limit_value, alloc = mk_allocator( + alloc=Allocator( symbol=symbol, account=None, # select paper by default @@ -571,6 +574,7 @@ async def open_order_mode( # order pane widgets and allocation model order_pane = SettingsPane( + tracker=pp_tracker, form=form, alloc=alloc, @@ -581,14 +585,10 @@ async def open_order_mode( step_label=form.bottom_label, limit_label=form.top_label, ) - # make fill bar and positioning snapshot - # XXX: this need to be called *before* the first - # pp tracker update(s) below to ensure the limit size unit has - # been correctly set prior to updating the line's pp size label - # (the one on the RHS). - # TODO: should probably split out the alloc config from the UI - # config startup steps.. + + # set startup limit value read during alloc init order_pane.on_ui_settings_change('limit', limit_value) + # make fill bar and positioning snapshot order_pane.update_status_ui(size=startup_pp.size) # top level abstraction which wraps all this crazyness into