Move allocator components to clearing sub-pkg

fsp_feeds
Tyler Goodlet 2021-09-06 22:05:42 -04:00
parent 343cb4b0ae
commit 214c622328
3 changed files with 313 additions and 290 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
'''
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 `<brokermod>.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

View File

@ -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 `<brokermod>.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

View File

@ -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