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
106
piker/pp.py
106
piker/pp.py
|
@ -15,9 +15,9 @@
|
||||||
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# 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..
|
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
|
from contextlib import contextmanager as cm
|
||||||
|
@ -86,9 +86,9 @@ def open_trade_ledger(
|
||||||
return toml.dump(ledger, cf)
|
return toml.dump(ledger, cf)
|
||||||
|
|
||||||
|
|
||||||
class TradeRecord(Struct):
|
class Transaction(Struct):
|
||||||
fqsn: str # normally fqsn
|
fqsn: str # normally fqsn
|
||||||
tid: Union[str, int]
|
tid: Union[str, int] # unique transaction id
|
||||||
size: float
|
size: float
|
||||||
price: float
|
price: float
|
||||||
cost: float # commisions or other additional costs
|
cost: float # commisions or other additional costs
|
||||||
|
@ -98,7 +98,7 @@ class TradeRecord(Struct):
|
||||||
# optional key normally derived from the broker
|
# optional key normally derived from the broker
|
||||||
# backend which ensures the instrument-symbol this record
|
# backend which ensures the instrument-symbol this record
|
||||||
# is for is truly unique.
|
# is for is truly unique.
|
||||||
symkey: Optional[Union[str, int]] = None
|
bsuid: Optional[Union[str, int]] = None
|
||||||
|
|
||||||
|
|
||||||
class Position(Struct):
|
class Position(Struct):
|
||||||
|
@ -113,9 +113,14 @@ class Position(Struct):
|
||||||
# last size and avg entry price
|
# last size and avg entry price
|
||||||
size: float
|
size: float
|
||||||
avg_price: float # TODO: contextual pricing
|
avg_price: float # TODO: contextual pricing
|
||||||
|
bsuid: str
|
||||||
|
|
||||||
# ordered record of known constituent trade messages
|
# 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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
|
@ -160,6 +165,7 @@ class Position(Struct):
|
||||||
self,
|
self,
|
||||||
size: float,
|
size: float,
|
||||||
price: float,
|
price: float,
|
||||||
|
cost: float = 0,
|
||||||
|
|
||||||
# TODO: idea: "real LIFO" dynamic positioning.
|
# TODO: idea: "real LIFO" dynamic positioning.
|
||||||
# - when a trade takes place where the pnl for
|
# - when a trade takes place where the pnl for
|
||||||
|
@ -198,6 +204,8 @@ class Position(Struct):
|
||||||
self.avg_price = (
|
self.avg_price = (
|
||||||
abs(size) * price # weight of current exec
|
abs(size) * price # weight of current exec
|
||||||
+
|
+
|
||||||
|
cost # transaction cost
|
||||||
|
+
|
||||||
self.avg_price * abs(self.size) # weight of previous pp
|
self.avg_price * abs(self.size) # weight of previous pp
|
||||||
) / abs(new_size)
|
) / abs(new_size)
|
||||||
|
|
||||||
|
@ -207,9 +215,7 @@ class Position(Struct):
|
||||||
|
|
||||||
|
|
||||||
def update_pps(
|
def update_pps(
|
||||||
brokername: str,
|
records: dict[str, Transaction],
|
||||||
records: dict[str, TradeRecord],
|
|
||||||
|
|
||||||
pps: Optional[dict[str, Position]] = None
|
pps: Optional[dict[str, Position]] = None
|
||||||
|
|
||||||
) -> dict[str, Position]:
|
) -> dict[str, Position]:
|
||||||
|
@ -223,7 +229,7 @@ def update_pps(
|
||||||
for r in records:
|
for r in records:
|
||||||
|
|
||||||
pp = pps.setdefault(
|
pp = pps.setdefault(
|
||||||
r.fqsn or r.symkey,
|
r.fqsn or r.bsuid,
|
||||||
|
|
||||||
# if no existing pp, allocate fresh one.
|
# if no existing pp, allocate fresh one.
|
||||||
Position(
|
Position(
|
||||||
|
@ -233,27 +239,39 @@ def update_pps(
|
||||||
),
|
),
|
||||||
size=0.0,
|
size=0.0,
|
||||||
avg_price=0.0,
|
avg_price=0.0,
|
||||||
|
bsuid=r.bsuid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# don't do updates for ledger records we already have
|
# don't do updates for ledger records we already have
|
||||||
# included in the current pps state.
|
# included in the current pps state.
|
||||||
if r.tid in pp.fills:
|
if r.tid in pp.fills:
|
||||||
# NOTE: likely you'll see repeats of the same
|
# 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
|
# a ``brokerd.ib`` where the API will re-report trades from
|
||||||
# the current session, so we need to make sure we don't
|
# the current session, so we need to make sure we don't
|
||||||
# "double count" these in pp calculations.
|
# "double count" these in pp calculations.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# lifo style average price calc
|
# lifo style "breakeven" price calc
|
||||||
pp.lifo_update(r.size, r.price)
|
pp.lifo_update(
|
||||||
pp.fills.append(r.tid)
|
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)
|
assert len(set(pp.fills)) == len(pp.fills)
|
||||||
return pps
|
return pps
|
||||||
|
|
||||||
|
|
||||||
def _split_active(
|
def dump_active(
|
||||||
pps: dict[str, Position],
|
pps: dict[str, Position],
|
||||||
|
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
|
@ -277,9 +295,9 @@ def _split_active(
|
||||||
fqsn = pp.symbol.front_fqsn()
|
fqsn = pp.symbol.front_fqsn()
|
||||||
asdict = pp.to_dict()
|
asdict = pp.to_dict()
|
||||||
if pp.size == 0:
|
if pp.size == 0:
|
||||||
closed[fqsn] = asdict
|
closed[k] = asdict
|
||||||
else:
|
else:
|
||||||
active[fqsn] = asdict
|
active[k] = asdict
|
||||||
|
|
||||||
return active, closed
|
return active, closed
|
||||||
|
|
||||||
|
@ -292,7 +310,7 @@ def load_pps_from_ledger(
|
||||||
) -> tuple[dict, dict]:
|
) -> tuple[dict, dict]:
|
||||||
'''
|
'''
|
||||||
Open a ledger file by broker name and account and read in and
|
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
|
form and then pass these into the position processing routine
|
||||||
and deliver the two dict-sets of the active and closed pps.
|
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)
|
brokermod = get_brokermod(brokername)
|
||||||
records = brokermod.norm_trade_records(ledger)
|
records = brokermod.norm_trade_records(ledger)
|
||||||
pps = update_pps(brokername, records)
|
pps = update_pps(records)
|
||||||
|
return dump_active(pps)
|
||||||
return _split_active(pps)
|
|
||||||
|
|
||||||
|
|
||||||
def get_pps(
|
def get_pps(
|
||||||
|
@ -326,34 +343,34 @@ def get_pps(
|
||||||
def update_pps_conf(
|
def update_pps_conf(
|
||||||
brokername: str,
|
brokername: str,
|
||||||
acctid: str,
|
acctid: str,
|
||||||
trade_records: Optional[list[TradeRecord]] = None,
|
trade_records: Optional[list[Transaction]] = None,
|
||||||
|
|
||||||
) -> dict[str, Position]:
|
) -> dict[str, Position]:
|
||||||
|
|
||||||
conf, path = config.load('pps')
|
conf, path = config.load('pps')
|
||||||
brokersection = conf.setdefault(brokername, {})
|
brokersection = conf.setdefault(brokername, {})
|
||||||
entries = brokersection.setdefault(acctid, {})
|
accountsection = pps = brokersection.setdefault(acctid, {})
|
||||||
|
|
||||||
if not entries:
|
|
||||||
|
|
||||||
|
if not pps:
|
||||||
# no pps entry yet for this broker/account so parse
|
# no pps entry yet for this broker/account so parse
|
||||||
# any available ledgers to build a pps state.
|
# any available ledgers to build a pps state.
|
||||||
active, closed = load_pps_from_ledger(
|
pps, closed = load_pps_from_ledger(
|
||||||
brokername,
|
brokername,
|
||||||
acctid,
|
acctid,
|
||||||
)
|
)
|
||||||
|
if not pps:
|
||||||
|
log.warning(
|
||||||
|
f'No trade history could be loaded for {brokername}:{acctid}'
|
||||||
|
)
|
||||||
|
|
||||||
elif trade_records:
|
# unmarshal/load ``pps.toml`` config entries into object form.
|
||||||
|
pp_objs = {}
|
||||||
# table for map-back to object form
|
for fqsn, entry in pps.items():
|
||||||
pps = {}
|
pp_objs[fqsn] = Position(
|
||||||
|
|
||||||
# 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={}),
|
Symbol.from_fqsn(fqsn, info={}),
|
||||||
size=entry['size'],
|
size=entry['size'],
|
||||||
avg_price=entry['avg_price'],
|
avg_price=entry['avg_price'],
|
||||||
|
bsuid=entry['bsuid'],
|
||||||
|
|
||||||
# XXX: super critical, we need to be sure to include
|
# XXX: super critical, we need to be sure to include
|
||||||
# all pps.toml fills to avoid reusing fills that were
|
# all pps.toml fills to avoid reusing fills that were
|
||||||
|
@ -363,20 +380,18 @@ def update_pps_conf(
|
||||||
fills=entry['fills'],
|
fills=entry['fills'],
|
||||||
)
|
)
|
||||||
|
|
||||||
pps = update_pps(
|
# update all pp objects from any (new) trade records which
|
||||||
brokername,
|
# were passed in (aka incremental update case).
|
||||||
|
if trade_records:
|
||||||
|
pp_objs = update_pps(
|
||||||
trade_records,
|
trade_records,
|
||||||
pps=pps,
|
pps=pp_objs,
|
||||||
)
|
)
|
||||||
active, closed = _split_active(pps)
|
|
||||||
|
|
||||||
else:
|
active, closed = dump_active(pp_objs)
|
||||||
raise ValueError('wut wut')
|
|
||||||
|
|
||||||
for fqsn in closed:
|
|
||||||
print(f'removing closed pp: {fqsn}')
|
|
||||||
entries.pop(fqsn, None)
|
|
||||||
|
|
||||||
|
# dict-serialize all active pps
|
||||||
|
pp_entries = {}
|
||||||
for fqsn, pp_dict in active.items():
|
for fqsn, pp_dict in active.items():
|
||||||
print(f'Updating active pp: {fqsn}')
|
print(f'Updating active pp: {fqsn}')
|
||||||
|
|
||||||
|
@ -386,8 +401,9 @@ def update_pps_conf(
|
||||||
# XXX: ugh, it's cuz we push the section under
|
# XXX: ugh, it's cuz we push the section under
|
||||||
# the broker name.. maybe we need to rethink this?
|
# the broker name.. maybe we need to rethink this?
|
||||||
brokerless_key = fqsn.rstrip(f'.{brokername}')
|
brokerless_key = fqsn.rstrip(f'.{brokername}')
|
||||||
entries[brokerless_key] = pp_dict
|
pp_entries[brokerless_key] = pp_dict
|
||||||
|
|
||||||
|
conf[brokername][acctid] = pp_entries
|
||||||
config.write(
|
config.write(
|
||||||
conf,
|
conf,
|
||||||
'pps',
|
'pps',
|
||||||
|
|
Loading…
Reference in New Issue