# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)

# 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/>.

'''
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 dirt-bird friends)

'''
from __future__ import annotations
from contextlib import contextmanager as cm
from decimal import Decimal
from pprint import pformat
from pathlib import Path
from types import ModuleType
from typing import (
    Any,
    Iterator,
    Generator,
    TYPE_CHECKING,
)

import pendulum
from pendulum import (
    datetime,
    now,
)
import polars as pl
import tomlkit

from ._ledger import (
    Transaction,
    TransactionLedger,
)
from ._mktinfo import (
    MktPair,
    Asset,
    unpack_fqme,
)
from .calc import (
    ppu,
    # iter_by_dt,
)
from .. import config
from ..clearing._messages import (
    BrokerdPosition,
)
from piker.types import Struct
from piker.log import (
    get_logger,
)

if TYPE_CHECKING:
    from piker.data._symcache import SymbologyCache

log = get_logger(
    name=__name__,
)


class Position(Struct):
    '''
    An asset "position" model with attached clearing transaction history.

    A financial "position" in `piker` terms is a summary of accounting
    metrics computed from a transaction ledger; generally it describes
    some accumulative "size" and "average price" from the summarized
    underlying transaction set.

    In piker we focus on the `.ppu` (price per unit) and the `.bep`
    (break even price) including all transaction entries and exits since
    the last "net-zero" size of the destination asset's holding.

    This interface serves as an object API for computing and
    tracking positions as well as supports serialization for
    storage in the local file system (in TOML) and to interchange
    as a msg over IPC.

    '''
    mkt: MktPair

    # can be +ve or -ve for long/short
    # size: float

    # "price-per-unit price" above or below which pnl moves above and
    # below zero for the entirety of the current "trade state". The ppu
    # is only modified on "increases of" the absolute size of a position
    # in one of a long/short "direction" (i.e. abs(.size_i) > 0 after
    # the next transaction given .size was > 0 before that tx, and vice
    # versa for -ve sized positions).
    # ppu: float

    # TODO: break-even-price support!
    # bep: float

    # unique "backend system market id"
    bs_mktid: str

    split_ratio: int | None = None

    # TODO: use a `pl.DataFrame` intead?
    _events: dict[str, Transaction | dict] = {}

    @property
    def expiry(self) -> datetime | None:
        '''
        Security expiry if it has a limited lifetime.

        For non-derivative markets this is normally `None`.

        '''
        exp: str | None = self.mkt.expiry
        if exp is None:
            return None

        match exp.lower():
            # empty str, 'perp' (contract) or simply a null
            # signifies instrument with NO expiry.
            case 'perp' | '' | None:
                return None

            case str():
                return pendulum.parse(exp)

            case _:
                raise ValueError(
                    f'Unhandled `MktPair.expiry`: `{exp}`'
                )

    # TODO: idea: "real LIFO" dynamic positioning.
    # - when a trade takes place where the pnl for
    # the (set of) trade(s) is below the breakeven price
    # it may be that the trader took a +ve pnl on a short(er)
    # term trade in the same account.
    # - in this case we could recalc the be price to
    # be reverted back to it's prior value before the nearest term
    # trade was opened.?
    # def bep() -> float:
    #     ...
    def clears_df(self) -> pl.DataFrame:
        ...

    def clearsitems(self) -> list[(str, dict)]:
        return ppu(
            self.iter_by_type('clear'),
            as_ledger=True
        )

    def iter_by_type(
        self,
        etype: str,

    ) -> Iterator[dict | Transaction]:
        '''
        Iterate the internally managed ``._events: dict`` table in
        datetime-stamped order.

        '''
        # sort on the expected datetime field
        # for event in iter_by_dt(
        for event in sorted(
            self._events.values(),
            key=lambda entry: entry.dt
        ):
            # if event.etype == etype:
            match event:
                case (
                    {'etype': _etype} |
                    Transaction(etype=str(_etype))
                ):
                    assert _etype == etype
                    yield event


    def minimized_clears(self) -> dict[str, dict]:
        '''
        Minimize the position's clears entries by removing
        all transactions before the last net zero size except for when
        a clear event causes a position "side" change (i.e. long to short
        after a single fill) wherein we store the transaction prior to the
        net-zero pass.

        This avoids unnecessary history irrelevant to the current
        non-net-zero size state when serializing for offline storage.

        '''
        # scan for the last "net zero" position by iterating
        # transactions until the next net-zero cumsize, rinse,
        # repeat.
        cumsize: float = 0
        clears_since_zero: list[dict] = []

        for tid, cleardict in self.clearsitems():
            cumsize = float(
                # self.mkt.quantize(cumsize + cleardict['tx'].size
                self.mkt.quantize(cleardict['cumsize'])
            )
            clears_since_zero.append(cleardict)

            # NOTE: always pop sign change since we just use it to
            # determine which entry to clear "up to".
            sign_change: bool = cleardict.pop('sign_change')
            if cumsize == 0:
                clears_since_zero = clears_since_zero[:-2]
                # clears_since_zero.clear()

            elif sign_change:
                clears_since_zero = clears_since_zero[:-1]

        return clears_since_zero

    def to_pretoml(self) -> tuple[str, dict]:
        '''
        Prep this position's data contents for export as an entry
        in a TOML "account file" (such as
        `account.binance.paper.toml`) including re-structuring of
        the ``._events`` entries as an array of inline-subtables
        for better ``pps.toml`` compactness.

        '''
        mkt: MktPair = self.mkt
        assert isinstance(mkt, MktPair)

        # TODO: we need to figure out how to have one top level
        # listing venue here even when the backend isn't providing
        # it via the trades ledger..
        # drop symbol obj in serialized form
        fqme: str = mkt.fqme
        broker, mktep, venue, suffix = unpack_fqme(fqme)

        # an asset resolved mkt where we have ``Asset`` info about
        # each tradeable asset in the market.
        asset_type: str = 'n/a'
        if mkt.resolved:
            dst: Asset = mkt.dst
            asset_type = dst.atype

        asdict: dict[str, Any] = {
            'bs_mktid': self.bs_mktid,
            # 'expiry': self.expiry or '',
            'asset_type': asset_type,
            'price_tick': mkt.price_tick,
            'size_tick': mkt.size_tick,
        }
        if exp := self.expiry:
            asdict['expiry'] = exp

        clears_since_zero: list[dict] = self.minimized_clears()

        # setup a "multi-line array of inline tables" which we call
        # the "clears table", contained by each position entry in
        # an "account file".
        clears_table: tomlkit.Array = tomlkit.array()
        clears_table.multiline(
            multiline=True,
            indent='',
        )

        for entry in clears_since_zero:
            inline_table = tomlkit.inline_table()

            # insert optional clear fields in column order
            for k in ['ppu', 'cumsize']:
                if val := entry.get(k):
                    inline_table[k] = val

            # insert required fields
            for k in ['price', 'size', 'cost']:
                inline_table[k] = entry[k]

            # NOTE: we don't actually need to serialize datetime to parsable `str`
            # since `tomlkit` supports a native `DateTime` but
            # seems like we're not doing it entirely in clearing
            # tables yet?
            inline_table['dt'] = entry['dt']  # .isoformat('T')

            tid: str = entry['tid']
            inline_table['tid'] = tid
            clears_table.append(inline_table)

        # assert not events
        asdict['clears'] = clears_table

        return fqme, asdict

    def update_from_msg(
        self,
        msg: BrokerdPosition,

    ) -> None:
        '''
        Hard-set the current position from a remotely-received
        (normally via IPC) msg by applying the msg as the one (and
        only) txn in the `._events` table thus forcing the current
        asset allocation blindly.

        '''
        mkt: MktPair = self.mkt
        now_dt: pendulum.DateTime = now()
        now_str: str = str(now_dt)

        # XXX: wipe all prior txn history since we wanted it we wouldn't
        # be using this method to compute our state!
        self._events.clear()

        # NOTE WARNING XXX: we summarize the pos with a single
        # summary transaction (for now) until we either pass THIS
        # type as msg directly from emsd or come up with a better
        # way?
        t = Transaction(
            fqme=mkt.fqme,
            bs_mktid=mkt.bs_mktid,
            size=msg['size'],
            price=msg['avg_price'],
            cost=0,

            # NOTE: special provisions required!
            # - tid needs to be unique or this txn will be ignored!!
            tid=now_str,

            # TODO: also figure out how to avoid this!
            dt=now_dt,
        )
        self.add_clear(t)

    @property
    def dsize(self) -> float:
        '''
        The "dollar" size of the pp, normally in source asset
        (fiat) units.

        '''
        return self.ppu * self.cumsize

    def expired(self) -> bool:
        '''
        Predicate which checks if the contract/instrument is past
        its expiry.

        '''
        return bool(self.expiry) and self.expiry < now()

    def add_clear(
        self,
        t: Transaction,
    ) -> bool:
        '''
        Update clearing table by calculating the rolling ppu and
        (accumulative) size in both the clears entry and local attrs
        state.

        Inserts are always done in datetime sorted order.

        '''
        tid: str = t.tid
        if tid in self._events:
            log.debug(
                f'Txn is already added?\n'
                f'\n'
                f'{t}\n'
            )
            return False

        # TODO: apparently this IS possible with a dict but not
        # common and probably not that beneficial unless we're also
        # going to do cum-calcs on each insert?
        # https://stackoverflow.com/questions/38079171/python-insert-new-element-into-sorted-list-of-dictionaries
        # from bisect import insort
        # insort(
        #     self._clears,
        #     clear,
        #     key=lambda entry: entry['dt']
        # )
        self._events[tid] = t
        return True

    # TODO: compute these incrementally instead
    # of re-looping through each time resulting in O(n**2)
    # behaviour..? Can we have some kinda clears len to cached
    # output subsys?
    def calc_ppu(self) -> float:
        return ppu(self.iter_by_type('clear'))

        # # return self.clearsdict()
        # # )
        # return list(self.clearsdict())[-1][1]['ppu']

    @property
    def ppu(self) -> float:
        return round(
            self.calc_ppu(),
            ndigits=self.mkt.price_tick_digits,
        )

    def calc_size(self) -> float:
        '''
        Calculate the unit size of this position in the destination
        asset using the clears/trade event table; zero if expired.

        '''
        # time-expired pps (normally derivatives) are "closed"
        # and have a zero size.
        if self.expired():
            return 0.

        clears: list[(str, dict)] = self.clearsitems()
        if clears:
            return clears[-1][1]['cumsize']
        else:
            return 0.

        # if self.split_ratio is not None:
        #     size = round(size * self.split_ratio)

        # return float(
        #     self.mkt.quantize(size),
        # )

    # TODO: ideally we don't implicitly recompute the
    # full sequence from `.clearsdict()` every read..
    # the writer-updates-local-attr-state was actually kinda nice
    # before, but sometimes led to hard to detect bugs when
    # state was de-synced.
    @property
    def cumsize(self) -> float:

        if (
            self.expiry
            and self.expiry < now()
        ):
            return 0

        return round(
            self.calc_size(),
            ndigits=self.mkt.size_tick_digits,
        )

    # TODO: once we have an `.events` table with diff
    # mkt event types..?
    # def suggest_split(self) -> float:
    #     ...

    # ?TODO, for sending rendered state over the wire?
    # def summary(self) -> PositionSummary:
        # do minimal conversion to a subset of fields
        # currently defined in `.clearing._messages.BrokerdPosition`



class Account(Struct):
    '''
    The real-time (double-entry accounting) state of
    a given **asset ownership tracking system**, normally offered
    or measured from some brokerage, CEX or (implied virtual)
    summary crypto$ "wallets" aggregated and tracked over some set
    of DEX-es.

    Both market-mapped and ledger-system-native (aka inter-account
    "transfers") transactions are accounted and they pertain to
    (implied) PnL relatve to any other accountable asset.

    More specifically in piker terms, an account tracks all of:

    - the *balances* of all assets currently available for use either
      in (future) market or (inter-account/wallet) transfer
      transactions.
    - a transaction *ledger* from a given brokerd backend whic
      is a recording of all (know) such transactions from the past.
    - a set of financial *positions* as measured from the current
      ledger state.

    See the semantic origins from double-bookeeping:
    https://en.wikipedia.org/wiki/Double-entry_bookkeeping

    '''
    mod: ModuleType
    acctid: str
    pps: dict[str, Position]

    conf_path: Path
    conf: dict | None = {}

    # TODO: track a table of asset balances as `.balances:
    # dict[Asset, float]`?

    @property
    def brokername(self) -> str:
        return self.mod.name

    def update_from_ledger(
        self,
        ledger: TransactionLedger|dict[str, Transaction],
        cost_scalar: float = 2,
        symcache: SymbologyCache|None = None,

        _mktmap_table: dict[str, MktPair] | None = None,

        only_require: list[str]|True = True,
        # ^list of fqmes that are "required" to be processed from
        # this ledger pass; we often don't care about others and
        # definitely shouldn't always error in such cases.
        # (eg. broker backend loaded that doesn't yet supsport the
        # symcache but also, inside the paper engine we don't ad-hoc
        # request `get_mkt_info()` for every symbol in the ledger,
        # only the one for which we're simulating against).
        # TODO, not sure if there's a better soln for this, ideally
        # all backends get symcache support afap i guess..

    ) -> dict[str, Position]:
        '''
        Update the internal `.pps[str, Position]` table from input
        transactions recomputing the price-per-unit (ppu) and
        accumulative size for each entry.

        '''
        if (
            not isinstance(ledger, TransactionLedger)
        ):
            if symcache is None:
                raise RuntimeError(
                    'No ledger provided!\n'
                    'We can not determine the `MktPair`s without a symcache..\n'
                    'Please provide `symcache: SymbologyCache` when '
                    'processing NEW positions!'
                )
            itertxns = sorted(
                ledger.values(),
                key=lambda t: t.dt,
            )
        else:
            itertxns = ledger.iter_txns()
            symcache = ledger.symcache

        pps = self.pps
        updated: dict[str, Position] = {}

        # lifo update all pps from records, ensuring
        # we compute the PPU and size sorted in time!
        for txn in itertxns:
            fqme: str = txn.fqme
            bs_mktid: str = txn.bs_mktid

            # template the mkt-info presuming a legacy market ticks
            # if no info exists in the transactions..
            try:
                mkt: MktPair = symcache.mktmaps[fqme]
            except KeyError:
                if _mktmap_table is None:
                    raise

                required: bool = (
                    only_require is True
                    or (
                        only_require is not True
                        and
                        fqme in only_require
                    )
                )
                # XXX: caller is allowed to provide a fallback
                # mktmap table for the case where a new position is
                # being added and the preloaded symcache didn't
                # have this entry prior (eg. with frickin IB..)
                if (
                    not (mkt := _mktmap_table.get(fqme))
                    and
                    required
                ):
                    raise

                elif not required:
                    continue

                else:
                    # should be an entry retreived somewhere
                    assert mkt


            if not (pos := pps.get(bs_mktid)):

                assert isinstance(
                    mkt,
                    MktPair,
                )

                # if no existing pos, allocate fresh one.
                pos = pps[bs_mktid] = Position(
                    mkt=mkt,
                    bs_mktid=bs_mktid,
                )
            else:
                # NOTE: if for some reason a "less resolved" mkt pair
                # info has been set (based on the `.fqme` being
                # a shorter string), instead use the one from the
                # transaction since it likely has (more) full
                # information from the provider.
                if len(pos.mkt.fqme) < len(fqme):
                    pos.mkt = mkt

            # update clearing acnt!
            # NOTE: likely you'll see repeats of the same
            # ``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; `Position.add_clear()` stores txs in
            # a `._events: dict[tid, tx]` which should always
            # ensure this is true!
            pos.add_clear(txn)
            updated[txn.bs_mktid] = pos

        # NOTE: deliver only the position entries that were
        # actually updated (modified the state) from the input
        # transaction set.
        return updated

    def dump_active(
        self,
    ) -> tuple[
        dict[str, Position],
        dict[str, Position]
    ]:
        '''
        Iterate all tabulated positions, render active positions to
        a ``dict`` format amenable to serialization (via TOML) and drop
        from state (``.pps``) as well as return in a ``dict`` all
        ``Position``s which have recently closed.

        '''
        # NOTE: newly closed position are also important to report/return
        # since a consumer, like an order mode UI ;), might want to react
        # based on the closure (for example removing the breakeven line
        # and clearing the entry from any lists/monitors).
        closed_pp_objs: dict[str, Position] = {}
        open_pp_objs: dict[str, Position] = {}

        pp_objs = self.pps
        for bs_mktid in list(pp_objs):
            pos = pp_objs[bs_mktid]
            # pos.ensure_state()

            # "net-zero" is a "closed" position
            if pos.cumsize == 0:
                # NOTE: we DO NOT pop the pos here since it can still be
                # used to check for duplicate clears that may come in as
                # new transaction from some backend API and need to be
                # ignored; the closed positions won't be written to the
                # ``pps.toml`` since ``pp_active_entries`` above is what's
                # written.
                closed_pp_objs[bs_mktid] = pos

            else:
                open_pp_objs[bs_mktid] = pos

        return open_pp_objs, closed_pp_objs

    def prep_toml(
        self,
        active: dict[str, Position] | None = None,

    ) -> dict[str, Any]:

        if active is None:
            active, _ = self.dump_active()

        # ONLY dict-serialize all active positions; those that are
        # closed we don't store in the ``pps.toml``.
        to_toml_dict: dict[str, Any] = {}

        pos: Position
        for bs_mktid, pos in active.items():
            # pos.ensure_state()

            # serialize to pre-toml form
            # NOTE: we only store the minimal amount of clears that
            # make up this position since the last net-zero state,
            # see `Position.to_pretoml()` for details
            fqme, asdict = pos.to_pretoml()

            # clears: list[dict] = asdict['clears']
            # assert 'Datetime' not in [0]['dt']
            log.info(f'Updating active pp: {fqme}')

            # XXX: ugh, it's cuz we push the section under
            # the broker name.. maybe we need to rethink this?
            brokerless_key = fqme.removeprefix(f'{self.brokername}.')
            to_toml_dict[brokerless_key] = asdict

        return to_toml_dict

    def write_config(self) -> None:
        '''
        Write the current account state to the user's account TOML file, normally
        something like `pps.toml`.

        '''
        # TODO: show diff output?
        # https://stackoverflow.com/questions/12956957/print-diff-of-python-dictionaries
        # active, closed_pp_objs = acnt.dump_active()

        active, closed = self.dump_active()
        pp_entries = self.prep_toml(active=active)
        if pp_entries:
            log.info(
                f'Updating positions in ``{self.conf_path}``:\n'
                f'n{pformat(pp_entries)}'
            )

            if self.brokername in self.conf:
                log.warning(
                    f'Rewriting {self.conf_path} keys to drop <broker.acct>!'
                )
                # legacy key schema including <brokername.account>, so
                # rewrite all entries to drop those tables since we now
                # put that in the filename!
                accounts = self.conf.pop(self.brokername)
                assert len(accounts) == 1
                entries = accounts.pop(self.acctid)
                self.conf.update(entries)

            self.conf.update(pp_entries)

            # drop any entries that are computed as net-zero
            # we don't care about storing in the pps file.
            if closed:
                bs_mktid: str
                for bs_mktid, pos in closed.items():
                    fqme: str = pos.mkt.fqme
                    if fqme in self.conf:
                        self.conf.pop(fqme)
                    else:
                        # TODO: we reallly need a diff set of
                        # loglevels/colors per subsys.
                        log.debug(
                            f'Recent position for {fqme} was closed!'
                        )

        # if there are no active position entries according
        # to the toml dump output above, then clear the config
        # file of all entries.
        elif self.conf:
            for entry in list(self.conf):
                del self.conf[entry]

        # XXX WTF: if we use a tomlkit.Integer here we get this
        # super weird --1 thing going on for cumsize!?1!
        # NOTE: the fix was to always float() the size value loaded
        # in open_account() below!
        config.write(
            config=self.conf,
            path=self.conf_path,
            fail_empty=False,
        )


def load_account(
    brokername: str,
    acctid: str,

    dirpath: Path | None = None,

) -> tuple[dict, Path]:
    '''
    Load a accounting (with positions) file from
    $CONFIG_DIR/accounting/account.<brokername>.<acctid>.toml

    Where normally $CONFIG_DIR = ~/.config/piker/
    and we implicitly create a accounting subdir which should
    normally be linked to a git repo managed by the user B)

    '''
    legacy_fn: str = f'pps.{brokername}.{acctid}.toml'
    fn: str = f'account.{brokername}.{acctid}.toml'

    dirpath: Path = dirpath or (config._config_dir / 'accounting')
    if not dirpath.is_dir():
        dirpath.mkdir()

    conf, path = config.load(
        path=dirpath / fn,
        decode=tomlkit.parse,
        touch_if_dne=True,
    )

    if not conf:
        legacypath = dirpath / legacy_fn
        log.warning(
            f'Your account file is using the legacy `pps.` prefix..\n'
            f'Rewriting contents to new name -> {path}\n'
            'Please delete the old file!\n'
            f'|-> {legacypath}\n'
        )
        if legacypath.is_file():
            legacy_config, _ = config.load(
                path=legacypath,

                # TODO: move to tomlkit:
                # - needs to be fixed to support bidict?
                #   https://github.com/sdispater/tomlkit/issues/289
                # - we need to use or fork's fix to do multiline array
                #   indenting.
                decode=tomlkit.parse,
            )
            conf.update(legacy_config)

            # XXX: override the presumably previously non-existant
            # file with legacy's contents.
            config.write(
                conf,
                path=path,
                fail_empty=False,
            )

    return conf, path


# TODO: make this async and offer a `get_account()` that
# can be used from sync code which does the same thing as
# open_trade_ledger()!
@cm
def open_account(
    brokername: str,
    acctid: str,
    write_on_exit: bool = False,

    # for testing or manual load from file
    _fp: Path | None = None,

) -> Generator[Account, None, None]:
    '''
    Read out broker-specific position entries from
    incremental update file: ``pps.toml``.

    '''
    conf: dict
    conf_path: Path
    conf, conf_path = load_account(
        brokername,
        acctid,
        dirpath=_fp,
    )

    if brokername in conf:
        log.warning(
            f'Rewriting {conf_path} keys to drop <broker.acct>!'
        )
        # legacy key schema including <brokername.account>, so
        # rewrite all entries to drop those tables since we now
        # put that in the filename!
        accounts = conf.pop(brokername)
        for acctid in accounts.copy():
            entries = accounts.pop(acctid)
            conf.update(entries)

    # TODO: ideally we can pass in an existing
    # pps state to this right? such that we
    # don't have to do a ledger reload all the
    # time.. a couple ideas I can think of,
    # - mirror this in some client side actor which
    #   does the actual ledger updates (say the paper
    #   engine proc if we decide to always spawn it?),
    # - do diffs against updates from the ledger writer
    #   actor and the in-mem state here?
    from ..brokers import get_brokermod
    mod: ModuleType = get_brokermod(brokername)

    pp_objs: dict[str, Position] = {}
    acnt = Account(
        mod,
        acctid,
        pp_objs,
        conf_path,
        conf=conf,
    )

    # unmarshal/load ``pps.toml`` config entries into object form
    # and update `Account` obj entries.
    for fqme, entry in conf.items():

        # unique broker-backend-system market id
        bs_mktid = str(
            entry.get('bsuid')
            or entry.get('bs_mktid')
        )
        price_tick = Decimal(str(
            entry.get('price_tick_size')
            or entry.get('price_tick')
            or '0.01'
        ))
        size_tick = Decimal(str(
            entry.get('lot_tick_size')
            or entry.get('size_tick')
            or '0.0'
        ))

        # load the pair using the fqme which
        # will make the pair "unresolved" until
        # the backend broker actually loads
        # the market and position info.
        mkt = MktPair.from_fqme(
            fqme,
            price_tick=price_tick,
            size_tick=size_tick,
            bs_mktid=bs_mktid,
        )

        # TODO: RE: general "events" instead of just "clears":
        # - make this an `events` field and support more event types
        #   such as 'split', 'name_change', 'mkt_info', etc..
        # - should be make a ``Struct`` for clear/event entries? convert
        #   "clear events table" from the toml config (list of a dicts)
        #   and load it into object form for use in position processing of
        #   new clear events.

        # convert clears sub-tables (only in this form
        # for toml re-presentation) back into a master table.
        toml_clears_list: list[dict[str, Any]] = entry['clears']
        trans: list[Transaction] = []

        for clears_table in toml_clears_list:
            tid = clears_table['tid']
            dt: tomlkit.items.DateTime | str = clears_table['dt']

            # woa cool, `tomlkit` will actually load datetimes into
            # native form B)
            if isinstance(dt, str):
                dt = pendulum.parse(dt)

            clears_table['dt'] = dt
            trans.append(Transaction(
                fqme=bs_mktid,
                bs_mktid=bs_mktid,
                tid=tid,
                # XXX: not sure why sometimes these are loaded as
                # `tomlkit.Integer` and are eventually written with
                # an extra `-` in front like `--1`?
                size=float(clears_table['size']),
                price=float(clears_table['price']),
                cost=clears_table['cost'],
                dt=dt,
            ))

        split_ratio = entry.get('split_ratio')

        # if a string-ified expiry field is loaded we try to parse
        # it, THO, they should normally be serialized as native
        # TOML datetimes, since that's supported.
        if (
            (expiry := entry.get('expiry'))
            and isinstance(expiry, str)
        ):
            expiry: pendulum.DateTime = pendulum.parse(expiry)

        # !XXX, should never be duplicates over
        # a backend-(broker)-system's unique market-IDs!
        if pos := pp_objs.get(bs_mktid):
            if mkt != pos.mkt:
                log.warning(
                    f'Duplicated position but diff `MktPair.fqme` ??\n'
                    f'bs_mktid: {bs_mktid!r}\n'
                    f'pos.mkt: {pos.mkt}\n'
                    f'mkt: {mkt}\n'
                )
        else:
            pos = pp_objs[bs_mktid] = Position(
                mkt,
                split_ratio=split_ratio,
                bs_mktid=bs_mktid,
            )

        # XXX: super critical, we need to be sure to include
        # all pps.toml clears to avoid reusing clears that were
        # already included in the current incremental update
        # state, since today's records may have already been
        # processed!
        for t in trans:
            added: bool = pos.add_clear(t)
            if not added:
                log.warning(
                    f'Txn already recorded in pp ??\n'
                    f'\n'
                    f'{t}\n'
                )
    try:
        yield acnt
    finally:
        if write_on_exit:
            acnt.write_config()


def load_account_from_ledger(

    brokername: str,
    acctname: str,

    # post normalization filter on ledger entries to be processed
    filter_by_ids: dict[str, list[str]] | None = None,

    ledger: TransactionLedger | None = None,
    **kwargs,

) -> Account:
    '''
    Open a ledger file by broker name and account and read in and
    process any trade records into our normalized ``Transaction`` form
    and then update the equivalent ``Pptable`` and deliver the two
    bs_mktid-mapped dict-sets of the transactions and pps.

    '''
    acnt: Account
    with open_account(
        brokername,
        acctname,
        **kwargs,
    ) as acnt:
        if ledger is not None:
            acnt.update_from_ledger(ledger)

    return acnt
