# 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 .
"""
Fake trading for forward testing.
"""
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime
from operator import itemgetter
import itertools
import time
from typing import (
    Any,
    Optional,
    Callable,
)
import uuid
from bidict import bidict
import pendulum
import trio
import tractor
from .. import data
from ..data._source import Symbol
from ..data.types import Struct
from ..pp import (
    Position,
    Transaction,
)
from ..data._normalize import iterticks
from ..data._source import unpack_fqsn
from ..log import get_logger
from ._messages import (
    BrokerdCancel,
    BrokerdOrder,
    BrokerdOrderAck,
    BrokerdStatus,
    BrokerdFill,
    BrokerdPosition,
    BrokerdError,
)
log = get_logger(__name__)
class PaperBoi(Struct):
    '''
    Emulates a broker order client providing approximately the same API
    and delivering an order-event response stream but with methods for
    triggering desired events based on forward testing engine
    requirements (eg open, closed, fill msgs).
    '''
    broker: str
    ems_trades_stream: tractor.MsgStream
    # map of paper "live" orders which be used
    # to simulate fills based on paper engine settings
    _buys: defaultdict[str, bidict]
    _sells: defaultdict[str, bidict]
    _reqids: bidict
    _positions: dict[str, Position]
    _trade_ledger: dict[str, Any]
    # init edge case L1 spread
    last_ask: tuple[float, float] = (float('inf'), 0)  # price, size
    last_bid: tuple[float, float] = (0, 0)
    async def submit_limit(
        self,
        oid: str,  # XXX: see return value
        symbol: str,
        price: float,
        action: str,
        size: float,
        reqid: Optional[str],
    ) -> int:
        '''
        Place an order and return integer request id provided by client.
        '''
        if action == 'alert':
            # bypass all fill simulation
            return reqid
        entry = self._reqids.get(reqid)
        if entry:
            # order is already existing, this is a modify
            (oid, symbol, action, old_price) = entry
        else:
            # register order internally
            self._reqids[reqid] = (oid, symbol, action, price)
        # TODO: net latency model
        # we checkpoint here quickly particulalry
        # for dark orders since we want the dark_executed
        # to trigger first thus creating a lookup entry
        # in the broker trades event processing loop
        await trio.sleep(0.05)
        if action == 'sell':
            size = -size
        msg = BrokerdStatus(
            status='open',
            # account=f'paper_{self.broker}',
            account='paper',
            reqid=reqid,
            time_ns=time.time_ns(),
            filled=0.0,
            reason='paper_trigger',
            remaining=size,
            broker_details={'name': 'paperboi'},
        )
        await self.ems_trades_stream.send(msg)
        # if we're already a clearing price simulate an immediate fill
        if (
            action == 'buy' and (clear_price := self.last_ask[0]) <= price
            ) or (
            action == 'sell' and (clear_price := self.last_bid[0]) >= price
        ):
            await self.fake_fill(
                symbol,
                clear_price,
                size,
                action,
                reqid,
                oid,
            )
        # register this submissions as a paper live order
        else:
            # set the simulated order in the respective table for lookup
            # and trigger by the simulated clearing task normally
            # running ``simulate_fills()``.
            if action == 'buy':
                orders = self._buys
            elif action == 'sell':
                orders = self._sells
            # {symbol -> bidict[oid, ()]}
            orders[symbol][oid] = (price, size, reqid, action)
        return reqid
    async def submit_cancel(
        self,
        reqid: str,
    ) -> None:
        # TODO: fake market simulation effects
        oid, symbol, action, price = self._reqids[reqid]
        if action == 'buy':
            self._buys[symbol].pop(oid, None)
        elif action == 'sell':
            self._sells[symbol].pop(oid, None)
        # TODO: net latency model
        await trio.sleep(0.05)
        msg = BrokerdStatus(
            status='canceled',
            account='paper',
            reqid=reqid,
            time_ns=time.time_ns(),
            broker_details={'name': 'paperboi'},
        )
        await self.ems_trades_stream.send(msg)
    async def fake_fill(
        self,
        fqsn: str,
        price: float,
        size: float,
        action: str,  # one of {'buy', 'sell'}
        reqid: str,
        oid: str,
        # determine whether to send a filled status that has zero
        # remaining lots to fill
        order_complete: bool = True,
        remaining: float = 0,
    ) -> None:
        '''
        Pretend to fill a broker order @ price and size.
        '''
        # TODO: net latency model
        await trio.sleep(0.05)
        fill_time_ns = time.time_ns()
        fill_time_s = time.time()
        fill_msg = BrokerdFill(
            reqid=reqid,
            time_ns=fill_time_ns,
            action=action,
            size=size,
            price=price,
            broker_time=datetime.now().timestamp(),
            broker_details={
                'paper_info': {
                    'oid': oid,
                },
                # mocking ib
                'name': self.broker + '_paper',
            },
        )
        log.info(f'Fake filling order:\n{fill_msg}')
        await self.ems_trades_stream.send(fill_msg)
        self._trade_ledger.update(fill_msg.to_dict())
        if order_complete:
            msg = BrokerdStatus(
                reqid=reqid,
                time_ns=time.time_ns(),
                # account=f'paper_{self.broker}',
                account='paper',
                status='closed',
                filled=size,
                remaining=0 if order_complete else remaining,
            )
            await self.ems_trades_stream.send(msg)
        # lookup any existing position
        key = fqsn.rstrip(f'.{self.broker}')
        pp = self._positions.setdefault(
            fqsn,
            Position(
                Symbol(
                    key=key,
                    broker_info={self.broker: {}},
                ),
                size=size,
                ppu=price,
                bsuid=key,
            )
        )
        t = Transaction(
            fqsn=fqsn,
            tid=oid,
            size=size,
            price=price,
            cost=0,  # TODO: cost model
            dt=pendulum.from_timestamp(fill_time_s),
            bsuid=key,
        )
        pp.add_clear(t)
        pp_msg = BrokerdPosition(
            broker=self.broker,
            account='paper',
            symbol=fqsn,
            # TODO: we need to look up the asset currency from
            # broker info. i guess for crypto this can be
            # inferred from the pair?
            currency='',
            size=pp.size,
            avg_price=pp.ppu,
        )
        await self.ems_trades_stream.send(pp_msg)
async def simulate_fills(
    quote_stream: tractor.MsgStream,  # noqa
    client: PaperBoi,
) -> None:
    # TODO: more machinery to better simulate real-world market things:
    # - slippage models, check what quantopian has:
    # https://github.com/quantopian/zipline/blob/master/zipline/finance/slippage.py
    #   * this should help with simulating partial fills in a fast moving mkt
    #     afaiu
    # - commisions models, also quantopian has em:
    # https://github.com/quantopian/zipline/blob/master/zipline/finance/commission.py
    # - network latency models ??
    # - position tracking:
    # https://github.com/quantopian/zipline/blob/master/zipline/finance/ledger.py
    # this stream may eventually contain multiple symbols
    async for quotes in quote_stream:
        for sym, quote in quotes.items():
            for tick in iterticks(
                quote,
                # dark order price filter(s)
                types=('ask', 'bid', 'trade', 'last')
            ):
                tick_price = tick['price']
                buys: bidict[str, tuple] = client._buys[sym]
                iter_buys = reversed(sorted(
                    buys.values(),
                    key=itemgetter(0),
                ))
                def buy_on_ask(our_price):
                    return tick_price <= our_price
                sells: bidict[str, tuple] = client._sells[sym]
                iter_sells = sorted(
                    sells.values(),
                    key=itemgetter(0)
                )
                def sell_on_bid(our_price):
                    return tick_price >= our_price
                match tick:
                    # on an ask queue tick, only clear buy entries
                    case {
                        'price': tick_price,
                        'type': 'ask',
                    }:
                        client.last_ask = (
                            tick_price,
                            tick.get('size', client.last_ask[1]),
                        )
                        iter_entries = zip(
                            iter_buys,
                            itertools.repeat(buy_on_ask)
                        )
                    # on a bid queue tick, only clear sell entries
                    case {
                        'price': tick_price,
                        'type': 'bid',
                    }:
                        client.last_bid = (
                            tick_price,
                            tick.get('size', client.last_bid[1]),
                        )
                        iter_entries = zip(
                            iter_sells,
                            itertools.repeat(sell_on_bid)
                        )
                    # TODO: fix this block, though it definitely
                    # costs a lot more CPU-wise
                    # - doesn't seem like clears are happening still on
                    #   "resting" limit orders?
                    case {
                        'price': tick_price,
                        'type': ('trade' | 'last'),
                    }:
                        # in the clearing price / last price case we
                        # want to iterate both sides of our book for
                        # clears since we don't know which direction the
                        # price is going to move (especially with HFT)
                        # and thus we simply interleave both sides (buys
                        # and sells) until one side clears and then
                        # break until the next tick?
                        def interleave():
                            for pair in zip(
                                iter_buys,
                                iter_sells,
                            ):
                                for order_info, pred in zip(
                                    pair,
                                    itertools.cycle([buy_on_ask, sell_on_bid]),
                                ):
                                    yield order_info, pred
                        iter_entries = interleave()
                    # NOTE: all other (non-clearable) tick event types
                    # - we don't want to sping the simulated clear loop
                    # below unecessarily and further don't want to pop
                    # simulated live orders prematurely.
                    case _:
                        continue
                # iterate all potentially clearable book prices
                # in FIFO order per side.
                for order_info, pred in iter_entries:
                    (our_price, size, reqid, action) = order_info
                    # print(order_info)
                    clearable = pred(our_price)
                    if clearable:
                        # pop and retreive order info
                        oid = {
                            'buy': buys,
                            'sell': sells
                        }[action].inverse.pop(order_info)
                        # clearing price would have filled entirely
                        await client.fake_fill(
                            fqsn=sym,
                            # todo slippage to determine fill price
                            price=tick_price,
                            size=size,
                            action=action,
                            reqid=reqid,
                            oid=oid,
                        )
async def handle_order_requests(
    client: PaperBoi,
    ems_order_stream: tractor.MsgStream,
) -> None:
    request_msg: dict
    async for request_msg in ems_order_stream:
        match request_msg:
            case {'action': ('buy' | 'sell')}:
                order = BrokerdOrder(**request_msg)
                account = order.account
                # error on bad inputs
                reason = None
                if account != 'paper':
                    reason = f'No account found:`{account}` (paper only)?'
                elif order.size == 0:
                    reason = 'Invalid size: 0'
                if reason:
                    log.error(reason)
                    await ems_order_stream.send(BrokerdError(
                        oid=order.oid,
                        symbol=order.symbol,
                        reason=reason,
                    ))
                    continue
                reqid = order.reqid or str(uuid.uuid4())
                # deliver ack that order has been submitted to broker routing
                await ems_order_stream.send(
                    BrokerdOrderAck(
                        oid=order.oid,
                        reqid=reqid,
                    )
                )
                # call our client api to submit the order
                reqid = await client.submit_limit(
                    oid=order.oid,
                    symbol=f'{order.symbol}.{client.broker}',
                    price=order.price,
                    action=order.action,
                    size=order.size,
                    # XXX: by default 0 tells ``ib_insync`` methods that
                    # there is no existing order so ask the client to create
                    # a new one (which it seems to do by allocating an int
                    # counter - collision prone..)
                    reqid=reqid,
                )
                log.info(f'Submitted paper LIMIT {reqid}:\n{order}')
            case {'action': 'cancel'}:
                msg = BrokerdCancel(**request_msg)
                await client.submit_cancel(
                    reqid=msg.reqid
                )
            case _:
                log.error(f'Unknown order command: {request_msg}')
_reqids: bidict[str, tuple] = {}
_buys: defaultdict[
    str,  # symbol
    bidict[
        str,  # oid
        tuple[float, float, str, str],  # order info
    ]
] = defaultdict(bidict)
_sells: defaultdict[
    str,  # symbol
    bidict[
        str,  # oid
        tuple[float, float, str, str],  # order info
    ]
] = defaultdict(bidict)
_positions: dict[str, Position] = {}
@tractor.context
async def trades_dialogue(
    ctx: tractor.Context,
    broker: str,
    fqsn: str,
    loglevel: str = None,
) -> None:
    tractor.log.get_console_log(loglevel)
    async with (
        data.open_feed(
            [fqsn],
            loglevel=loglevel,
        ) as feed,
    ):
        pp_msgs: list[BrokerdPosition] = []
        pos: Position
        token: str  # f'{symbol}.{self.broker}'
        for token, pos in _positions.items():
            pp_msgs.append(BrokerdPosition(
                broker=broker,
                account='paper',
                symbol=pos.symbol.front_fqsn(),
                size=pos.size,
                avg_price=pos.ppu,
            ))
        # TODO: load paper positions per broker from .toml config file
        # and pass as symbol to position data mapping: ``dict[str, dict]``
        await ctx.started((
            pp_msgs,
            ['paper'],
        ))
        async with (
            ctx.open_stream() as ems_stream,
            trio.open_nursery() as n,
        ):
            client = PaperBoi(
                broker,
                ems_stream,
                _buys=_buys,
                _sells=_sells,
                _reqids=_reqids,
                # TODO: load paper positions from ``positions.toml``
                _positions=_positions,
                # TODO: load postions from ledger file
                _trade_ledger={},
            )
            n.start_soon(
                handle_order_requests,
                client,
                ems_stream,
            )
            # paper engine simulator clearing task
            await simulate_fills(feed.streams[broker], client)
@asynccontextmanager
async def open_paperboi(
    fqsn: str,
    loglevel: str,
) -> Callable:
    '''
    Spawn a paper engine actor and yield through access to
    its context.
    '''
    broker, symbol, expiry = unpack_fqsn(fqsn)
    service_name = f'paperboi.{broker}'
    async with (
        tractor.find_actor(service_name) as portal,
        tractor.open_nursery() as tn,
    ):
        # only spawn if no paperboi already is up
        # (we likely don't need more then one proc for basic
        # simulated order clearing)
        if portal is None:
            log.info('Starting new paper-engine actor')
            portal = await tn.start_actor(
                service_name,
                enable_modules=[__name__]
            )
        async with portal.open_context(
            trades_dialogue,
            broker=broker,
            fqsn=fqsn,
            loglevel=loglevel,
        ) as (ctx, first):
            yield ctx, first