Skip to content

Commit

Permalink
CLI approval model added
Browse files Browse the repository at this point in the history
  • Loading branch information
miohtama committed Mar 4, 2022
1 parent d71fcae commit 4fbd486
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 14 deletions.
30 changes: 30 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Testing

## Prerequisites

- Install with `poetry`

Set up environment:

```shell

# We use production server to get datasets needed in tests
export TRADING_STRATEGY_API_KEY=""
```

## Running

To run the tests:

```shell
pytest
```

## Interactive tests

Some tests provide interactivity. By default everything runs non-interactively.
But to test the user interface you might want to run the tests with user input enabled.

```shell
USER_INTERACTION=true pytest
```
72 changes: 67 additions & 5 deletions tests/test_cli_approve_trades.py → tests/test_cli_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from eth_account import Account
from eth_typing import HexAddress
from hexbytes import HexBytes
from prompt_toolkit.application import create_app_session
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.output import DummyOutput
from web3 import EthereumTesterProvider, Web3
from web3.contract import Contract

Expand Down Expand Up @@ -210,6 +213,12 @@ def strategy_path() -> Path:
return Path(os.path.join(os.path.dirname(__file__), "strategies", "random_eth_usdc.py"))


@pytest.fixture()
def recorded_input() -> bool:
"""Do we do interactive execution where the user presses the key, or use recorded key presses."""
return os.environ.get("USER_INTERACTION") is None


def test_cli_approve_trades(
logger: logging.Logger,
strategy_path: Path,
Expand All @@ -222,8 +231,54 @@ def test_cli_approve_trades(
weth_usdc_pair,
weth_token,
usdc_token,
recorded_input,
):
"""CLI approval dialog can approve trades."""

factory = import_strategy_file(strategy_path)
approval_model = CLIApprovalModel()
execution_model = UniswapV2ExecutionModel(state, uniswap_v2, hot_wallet)
sync_method = EthereumHotWalletReserveSyncer(web3, hot_wallet.address)
revaluation_method = UniswapV2PoolRevaluator(uniswap_v2)
pricing_method = UniswapV2LivePricing(uniswap_v2, universe.pairs)

runner: StrategyRunner = factory(
timed_task_context_manager=timed_task,
execution_model=execution_model,
approval_model=approval_model,
revaluation_method=revaluation_method,
sync_method=sync_method,
pricing_method=pricing_method,
reserve_assets=supported_reserves)

if recorded_input:
# See hints at https://github.com/MarcoMernberger/mgenomicsremotemail/blob/ac5fbeaf02ae80b0c573c6361c9279c540b933e4/tests/tmp.py#L27
inp = create_pipe_input()
keys = " \t\r" # Toggle checkbox with space, tab to ok, press enter
inp.send_text(keys)
with create_app_session(input=inp, output=DummyOutput()):
debug_details = runner.tick(datetime.datetime(2020, 1, 1), universe, state)
else:
debug_details = runner.tick(datetime.datetime(2020, 1, 1), universe, state)

assert len(debug_details["approved_trades"]) == 1


def test_cli_disapprove_trades(
logger: logging.Logger,
strategy_path: Path,
web3: Web3,
hot_wallet: HotWallet,
uniswap_v2: UniswapV2Deployment,
universe: Universe,
state: State,
supported_reserves,
weth_usdc_pair,
weth_token,
usdc_token,
recorded_input,
):
"""Render the CLI approval dialog for the trades correctly."""
"""CLI approval dialog can approve trades."""

factory = import_strategy_file(strategy_path)
approval_model = CLIApprovalModel()
Expand All @@ -241,7 +296,14 @@ def test_cli_approve_trades(
pricing_method=pricing_method,
reserve_assets=supported_reserves)

# Run 5 days
for i in range(1, 5):
# Run the trading for the first 3 days starting on arbitrarily chosen date 1-1-2020
runner.tick(datetime.datetime(2020, 1, i), universe, state)
if recorded_input:
# See hints at https://github.com/MarcoMernberger/mgenomicsremotemail/blob/ac5fbeaf02ae80b0c573c6361c9279c540b933e4/tests/tmp.py#L27
inp = create_pipe_input()
keys = "\t\r" # Skip checkbox with tab, press enter
inp.send_text(keys)
with create_app_session(input=inp, output=DummyOutput()):
debug_details = runner.tick(datetime.datetime(2020, 1, 1), universe, state)
else:
debug_details = runner.tick(datetime.datetime(2020, 1, 1), universe, state)

assert len(debug_details["approved_trades"]) == 0
62 changes: 54 additions & 8 deletions tradeexecutor/cli/approval.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"""Approve new trades in the console."""

from typing import List
import textwrap

from prompt_toolkit import print_formatted_text, HTML
from prompt_toolkit.shortcuts import checkboxlist_dialog

from tradeexecutor.state.state import State, TradeExecution
from tradeexecutor.state.state import State, TradeExecution, Portfolio, TradingPosition
from tradeexecutor.strategy.approval import ApprovalModel


Expand All @@ -12,15 +18,55 @@ class CLIApprovalModel(ApprovalModel):
If no one is there to press a key then nothing happens.
"""

def render_trades(self):
pass
def render_portfolio(self, portfolio: Portfolio) -> HTML:
"""Render the current portfolio holdings using ANSI formatting.
def confirm_trades(self, state: State, trades: List[TradeExecution]) -> List[TradeExecution]:
https://python-prompt-toolkit.readthedocs.io/en/master/pages/printing_text.html
:return: promp_toolkit HTML for displaying the portfolio
"""
:param state:
:param trades:
:return:
equity = portfolio.get_total_equity()
cash = portfolio.get_current_cash()
text = textwrap.dedent(f"""
Total equity <ansigreen>${equity:,.2f}</ansigreen>
Current cash <ansigreen>${cash:,.2f}</ansigreen>""")
text += '\n'

positions: List[TradingPosition] = list(portfolio.get_executed_positions())

if positions:
for tp in positions:
text += f"<b>{tp.get_name()}</b>: <ansigreen>${tp.get_value():,.2f}</ansigreen> at quantity of <ansigreen>{tp.get_quantity()} {tp.get_quantity_unit_name()}</ansigreen>\n"
else:
text += f"<ansired>No open positions</ansired>"

return HTML(text)

def confirm_trades(self, state: State, trades: List[TradeExecution]) -> List[TradeExecution]:
"""Create a checkbox list to approve trades using prompt_toolkit.
See https://python-prompt-toolkit.readthedocs.io/en/master/pages/dialogs.html#checkbox-list-dialog
:param state: The current trade execution state
:param trades: New trades to be executed
:return: What trades went through human approval
"""
import ipdb ; ipdb.set_trace()

# Show the user what we got
text = self.render_portfolio(state.portfolio)

new_trades = [
(t.trade_id, t.get_human_description())
for t in trades
]

approvals_dialog = checkboxlist_dialog(
title="New trades to execute",
text=text,
values=new_trades,
)
approvals = approvals_dialog.run()

return [t for t in trades if t.trade_id in approvals]


5 changes: 4 additions & 1 deletion tradeexecutor/ethereum/universe.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@


def create_pair_universe(web3: Web3, exchange: Exchange, pairs: List[TradingPairIdentifier]) -> PandasPairUniverse:
"""Creates a PairUniverse by giving a list of trading pairs."""
"""Creates a PairUniverse from Trade Executor test data.
PairUniverse is used by QSTrader based tests, so we need to support it.
"""

chain_id = ChainId(web3.eth.chain_id)

Expand Down
38 changes: 38 additions & 0 deletions tradeexecutor/state/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ def __post_init__(self):
assert self.planned_reserve >= 0
assert self.opened_at.tzinfo is None, f"We got a datetime {self.opened_at} with tzinfo {self.opened_at.tzinfo}"

def get_human_description(self) -> str:
"""User friendly description for this trade"""
if self.is_buy():
return f"Buy {self.planned_quantity} {self.pair.base.token_symbol} at {self.planned_price}"
else:
return f"Sell {self.planned_quantity} {self.pair.base.token_symbol} at {self.planned_price}"

def is_sell(self):
return self.planned_quantity < 0

Expand Down Expand Up @@ -515,11 +522,32 @@ def __post_init__(self):
assert self.last_reserve_price > 0

def is_open(self) -> bool:
"""This is an open trading position."""
return self.closed_at is None

def is_closed(self) -> bool:
"""This position has been closed and does not have any capital tied to it."""
return not self.is_open()

def has_executed_trades(self) -> bool:
"""This position represents actual holdings and has executed trades on it.
This will return false for positions that are still planned or have zero successful trades.
"""
t: TradeExecution
for t in self.trades.values():
if t.is_success():
return True
return False

def get_name(self) -> str:
"""Get human readable name for this position"""
return f"#{self.position_id} {self.pair.base.token_symbol}-{self.pair.quote.token_symbol}"

def get_quantity_unit_name(self) -> str:
"""Get the unit name we label the quantity in this position"""
return f"{self.pair.base.token_symbol}"

def get_quantity(self) -> Decimal:
"""Get the tied up token quantity in all successfully executed trades.
Expand Down Expand Up @@ -638,6 +666,16 @@ def get_all_positions(self) -> Iterable[TradingPosition]:
"""Get open and closed positions."""
return chain(self.open_positions.values(), self.closed_positions.values())

def get_executed_positions(self) -> Iterable[TradingPosition]:
"""Get all positions with already executed trades.
Ignore positions that are still pending - they have only planned trades.
"""
p: TradingPosition
for p in self.open_positions.values():
if p.has_executed_trades():
yield p

def get_open_position_for_pair(self, pair: TradingPairIdentifier) -> Optional[TradingPosition]:
assert isinstance(pair, TradingPairIdentifier)
pos: TradingPosition
Expand Down
1 change: 1 addition & 0 deletions tradeexecutor/strategy/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ def tick(self, clock: datetime.datetime, universe: Universe, state: State) -> di
approved_trades = self.approval_model.confirm_trades(state, rebalance_trades)
assert type(approved_trades) == list
logger.info("After approval we have %d trades left", len(approved_trades))
debug_details["approved_trades"] = approved_trades

with self.timed_task_context_manager("execute_trades"):
self.execution_model.execute_trades(clock, approved_trades)
Expand Down

0 comments on commit 4fbd486

Please sign in to comment.