Add transaction costs to "fills"
This makes a few major changes but mostly is centered around including transaction (aka trade-clear) costs in the avg breakeven price calculation. TL;DR: - rename `TradeRecord` -> `Transaction`. - make `Position.fills` a `dict[str, float]` which holds each clear's cost value. - change `Transaction.symkey` -> `.bsuid` for "backend symbol unique id". - drop `brokername: str` arg to `update_pps()` - rename `._split_active()` -> `dump_active()` and use input keys verbatim in output map. - in `update_pps_conf()` always incrementally update from trade records even when no `pps.toml` exists yet since it may be both the case that the ledger needs loading **and** the caller is handing new records not yet in the ledger.lifo_pps_ib
parent
c1b63f4757
commit
412138a75b
128
piker/pp.py
128
piker/pp.py
|
@ -15,9 +15,9 @@
|
|||
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
'''
|
||||
Personal/Private position parsing, calculmating, summarizing in a way
|
||||
Personal/Private position parsing, calculating, summarizing in a way
|
||||
that doesn't try to cuk most humans who prefer to not lose their moneys..
|
||||
(looking at you `ib` and shitzy friends)
|
||||
(looking at you `ib` and dirt-bird friends)
|
||||
|
||||
'''
|
||||
from contextlib import contextmanager as cm
|
||||
|
@ -86,9 +86,9 @@ def open_trade_ledger(
|
|||
return toml.dump(ledger, cf)
|
||||
|
||||
|
||||
class TradeRecord(Struct):
|
||||
class Transaction(Struct):
|
||||
fqsn: str # normally fqsn
|
||||
tid: Union[str, int]
|
||||
tid: Union[str, int] # unique transaction id
|
||||
size: float
|
||||
price: float
|
||||
cost: float # commisions or other additional costs
|
||||
|
@ -98,7 +98,7 @@ class TradeRecord(Struct):
|
|||
# optional key normally derived from the broker
|
||||
# backend which ensures the instrument-symbol this record
|
||||
# is for is truly unique.
|
||||
symkey: Optional[Union[str, int]] = None
|
||||
bsuid: Optional[Union[str, int]] = None
|
||||
|
||||
|
||||
class Position(Struct):
|
||||
|
@ -113,9 +113,14 @@ class Position(Struct):
|
|||
# last size and avg entry price
|
||||
size: float
|
||||
avg_price: float # TODO: contextual pricing
|
||||
bsuid: str
|
||||
|
||||
# ordered record of known constituent trade messages
|
||||
fills: list[Union[str, int, Status]] = []
|
||||
fills: dict[
|
||||
Union[str, int, Status], # trade id
|
||||
float, # cost
|
||||
] = {}
|
||||
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
@ -160,6 +165,7 @@ class Position(Struct):
|
|||
self,
|
||||
size: float,
|
||||
price: float,
|
||||
cost: float = 0,
|
||||
|
||||
# TODO: idea: "real LIFO" dynamic positioning.
|
||||
# - when a trade takes place where the pnl for
|
||||
|
@ -198,6 +204,8 @@ class Position(Struct):
|
|||
self.avg_price = (
|
||||
abs(size) * price # weight of current exec
|
||||
+
|
||||
cost # transaction cost
|
||||
+
|
||||
self.avg_price * abs(self.size) # weight of previous pp
|
||||
) / abs(new_size)
|
||||
|
||||
|
@ -207,9 +215,7 @@ class Position(Struct):
|
|||
|
||||
|
||||
def update_pps(
|
||||
brokername: str,
|
||||
records: dict[str, TradeRecord],
|
||||
|
||||
records: dict[str, Transaction],
|
||||
pps: Optional[dict[str, Position]] = None
|
||||
|
||||
) -> dict[str, Position]:
|
||||
|
@ -223,7 +229,7 @@ def update_pps(
|
|||
for r in records:
|
||||
|
||||
pp = pps.setdefault(
|
||||
r.fqsn or r.symkey,
|
||||
r.fqsn or r.bsuid,
|
||||
|
||||
# if no existing pp, allocate fresh one.
|
||||
Position(
|
||||
|
@ -233,27 +239,39 @@ def update_pps(
|
|||
),
|
||||
size=0.0,
|
||||
avg_price=0.0,
|
||||
bsuid=r.bsuid,
|
||||
)
|
||||
)
|
||||
# don't do updates for ledger records we already have
|
||||
# included in the current pps state.
|
||||
if r.tid in pp.fills:
|
||||
# NOTE: likely you'll see repeats of the same
|
||||
# ``TradeRecord`` passed in here if/when you are restarting
|
||||
# ``Transaction`` passed in here if/when you are restarting
|
||||
# a ``brokerd.ib`` where the API will re-report trades from
|
||||
# the current session, so we need to make sure we don't
|
||||
# "double count" these in pp calculations.
|
||||
continue
|
||||
|
||||
# lifo style average price calc
|
||||
pp.lifo_update(r.size, r.price)
|
||||
pp.fills.append(r.tid)
|
||||
# lifo style "breakeven" price calc
|
||||
pp.lifo_update(
|
||||
r.size,
|
||||
r.price,
|
||||
|
||||
# include transaction cost in breakeven price
|
||||
# and presume the worst case of the same cost
|
||||
# to exit this transaction (even though in reality
|
||||
# it will be dynamic based on exit stratetgy).
|
||||
cost=2*r.cost,
|
||||
)
|
||||
|
||||
# track clearing costs
|
||||
pp.fills[r.tid] = r.cost
|
||||
|
||||
assert len(set(pp.fills)) == len(pp.fills)
|
||||
return pps
|
||||
|
||||
|
||||
def _split_active(
|
||||
def dump_active(
|
||||
pps: dict[str, Position],
|
||||
|
||||
) -> tuple[
|
||||
|
@ -277,9 +295,9 @@ def _split_active(
|
|||
fqsn = pp.symbol.front_fqsn()
|
||||
asdict = pp.to_dict()
|
||||
if pp.size == 0:
|
||||
closed[fqsn] = asdict
|
||||
closed[k] = asdict
|
||||
else:
|
||||
active[fqsn] = asdict
|
||||
active[k] = asdict
|
||||
|
||||
return active, closed
|
||||
|
||||
|
@ -292,7 +310,7 @@ def load_pps_from_ledger(
|
|||
) -> tuple[dict, dict]:
|
||||
'''
|
||||
Open a ledger file by broker name and account and read in and
|
||||
process any trade records into our normalized ``TradeRecord``
|
||||
process any trade records into our normalized ``Transaction``
|
||||
form and then pass these into the position processing routine
|
||||
and deliver the two dict-sets of the active and closed pps.
|
||||
|
||||
|
@ -305,9 +323,8 @@ def load_pps_from_ledger(
|
|||
|
||||
brokermod = get_brokermod(brokername)
|
||||
records = brokermod.norm_trade_records(ledger)
|
||||
pps = update_pps(brokername, records)
|
||||
|
||||
return _split_active(pps)
|
||||
pps = update_pps(records)
|
||||
return dump_active(pps)
|
||||
|
||||
|
||||
def get_pps(
|
||||
|
@ -326,57 +343,55 @@ def get_pps(
|
|||
def update_pps_conf(
|
||||
brokername: str,
|
||||
acctid: str,
|
||||
trade_records: Optional[list[TradeRecord]] = None,
|
||||
trade_records: Optional[list[Transaction]] = None,
|
||||
|
||||
) -> dict[str, Position]:
|
||||
|
||||
conf, path = config.load('pps')
|
||||
brokersection = conf.setdefault(brokername, {})
|
||||
entries = brokersection.setdefault(acctid, {})
|
||||
|
||||
if not entries:
|
||||
accountsection = pps = brokersection.setdefault(acctid, {})
|
||||
|
||||
if not pps:
|
||||
# no pps entry yet for this broker/account so parse
|
||||
# any available ledgers to build a pps state.
|
||||
active, closed = load_pps_from_ledger(
|
||||
pps, closed = load_pps_from_ledger(
|
||||
brokername,
|
||||
acctid,
|
||||
)
|
||||
|
||||
elif trade_records:
|
||||
|
||||
# table for map-back to object form
|
||||
pps = {}
|
||||
|
||||
# load ``pps.toml`` config entries back into object form.
|
||||
for fqsn, entry in entries.items():
|
||||
pps[f'{fqsn}.{brokername}'] = Position(
|
||||
Symbol.from_fqsn(fqsn, info={}),
|
||||
size=entry['size'],
|
||||
avg_price=entry['avg_price'],
|
||||
|
||||
# XXX: super critical, we need to be sure to include
|
||||
# all pps.toml fills to avoid reusing fills that were
|
||||
# already included in the current incremental update
|
||||
# state, since today's records may have already been
|
||||
# processed!
|
||||
fills=entry['fills'],
|
||||
if not pps:
|
||||
log.warning(
|
||||
f'No trade history could be loaded for {brokername}:{acctid}'
|
||||
)
|
||||
|
||||
pps = update_pps(
|
||||
brokername,
|
||||
trade_records,
|
||||
pps=pps,
|
||||
# unmarshal/load ``pps.toml`` config entries into object form.
|
||||
pp_objs = {}
|
||||
for fqsn, entry in pps.items():
|
||||
pp_objs[fqsn] = Position(
|
||||
Symbol.from_fqsn(fqsn, info={}),
|
||||
size=entry['size'],
|
||||
avg_price=entry['avg_price'],
|
||||
bsuid=entry['bsuid'],
|
||||
|
||||
# XXX: super critical, we need to be sure to include
|
||||
# all pps.toml fills to avoid reusing fills that were
|
||||
# already included in the current incremental update
|
||||
# state, since today's records may have already been
|
||||
# processed!
|
||||
fills=entry['fills'],
|
||||
)
|
||||
active, closed = _split_active(pps)
|
||||
|
||||
else:
|
||||
raise ValueError('wut wut')
|
||||
# update all pp objects from any (new) trade records which
|
||||
# were passed in (aka incremental update case).
|
||||
if trade_records:
|
||||
pp_objs = update_pps(
|
||||
trade_records,
|
||||
pps=pp_objs,
|
||||
)
|
||||
|
||||
for fqsn in closed:
|
||||
print(f'removing closed pp: {fqsn}')
|
||||
entries.pop(fqsn, None)
|
||||
active, closed = dump_active(pp_objs)
|
||||
|
||||
# dict-serialize all active pps
|
||||
pp_entries = {}
|
||||
for fqsn, pp_dict in active.items():
|
||||
print(f'Updating active pp: {fqsn}')
|
||||
|
||||
|
@ -386,8 +401,9 @@ def update_pps_conf(
|
|||
# XXX: ugh, it's cuz we push the section under
|
||||
# the broker name.. maybe we need to rethink this?
|
||||
brokerless_key = fqsn.rstrip(f'.{brokername}')
|
||||
entries[brokerless_key] = pp_dict
|
||||
pp_entries[brokerless_key] = pp_dict
|
||||
|
||||
conf[brokername][acctid] = pp_entries
|
||||
config.write(
|
||||
conf,
|
||||
'pps',
|
||||
|
|
Loading…
Reference in New Issue