diff --git a/analysis/financials.py b/analysis/financials.py index fbb6b31..022c670 100644 --- a/analysis/financials.py +++ b/analysis/financials.py @@ -1,6 +1,7 @@ from abc import ABC +import logging import pandas as pd -from typing import NoReturn, Union +from typing import NoReturn from warnings import warn from strategies.strategy import Strategy @@ -91,21 +92,27 @@ def _remaining(self) -> int: return self.order_count - len(self.incomplete) @property - def starting(self) -> Union[float, None]: - """ Amount of capitol to use for a trade. + def starting(self) -> float: + """ Amount of capitol to use for a buying assets. + + Value is computed dynamically so that trades grow larger as the amount of available capitol increases. A + fraction of available capitol is used instead of spending all available to protect against trading inactivity + during a price drop. + + Notes: + Since the returned value is used when buying assets, a warning is raised and logged when `_remaining` equals + to 0. - This fraction is dynamically calculated due to the assumption that order might remain incomplete. Flexibility - is desired when placing a new order. Returns: - Amount of capitol to use when beginning to calculate final trade amount. `None` is returned where there - `_remaining` returns 0 and therefore no more trades should be executed. + Amount of capitol to use when beginning to calculate final trade amount. """ - _remaining = self._remaining - if _remaining: - return self.capitol / self._remaining + if self._remaining == 0: + msg = '`starting` accessed while buying is restricted (`_remaining` == 0)' + warn(msg) + logging.warning(msg) - return None + return self.capitol / self.order_count def pnl(self) -> float: # TODO: `unpaired_buys` need to be reflected. Either buy including current price, or excluding and mentioning @@ -148,7 +155,7 @@ def _adjust_assets(self, trade: SuccessfulTrade) -> NoReturn: logging.warning(msg) def unpaired(self) -> pd.DataFrame: - """ Select of unpaired orders by cross-referencing `unpaired_buys`. + """ Select of unpaired orders by cross-referencing `incomplete`. This retrieves orders data whose assets have not been sold yet. Used during calculation of pnl, and during normal trading operation to attempt sale of unsold assets and to clear `incomplete` when assets are sold. @@ -163,7 +170,7 @@ def _check_unpaired(self, rate: float, original: bool = True) -> pd.DataFrame: Args: rate: - Select rows whose rate is <= given `rate`. Since buy orders cannot be sold at a rate lower than the + Select rows whose rate is <= given `rate`. Since buy orders should be sold at a rate lower than the buy price, rate is used to select incomplete order rows when determining amount of available assets should be sold. original: @@ -213,11 +220,11 @@ def _handle_inactive(self, row: pd.Series) -> NoReturn: assert type(row) is pd.Series assert row['side'] == Side.BUY - if row['id'] in self.incomplete['id']: + if row['id'] in self.incomplete['id'].values: warn('Adding duplicate id found in `incomplete`') _row = pd.DataFrame([[row['amt'], row['rate'], row['id']]], columns=['amt', 'rate', 'id']) - self.incomplete = pd.concat([self.incomplete, row], + self.incomplete = pd.concat([self.incomplete, _row], ignore_index=True) def _clean_incomplete(self, trade: SuccessfulTrade): @@ -270,23 +277,21 @@ def _deduct_sold(self, trade: SuccessfulTrade, unpaired: pd.DataFrame) -> NoRetu TODO: - Track related orders """ - - # deduct from oldest unpaired order - unpaired.sort_index(inplace=True) + assert trade.side == Side.SELL # account for assets acquired from previous buy last_buy = self.orders[self.orders['side'] == Side.BUY].iloc[-1] - amt = trade.amt - last_buy['amt'] + excess = trade.amt - last_buy['amt'] # deduct excess from unpaired orders _drop = [] # rows to drop for index, order in unpaired.iterrows(): - if amt >= order['amt']: - _drop.append(order.name) - amt -= order['amt'] + if excess >= order['amt']: + _drop.append(index) + excess -= order['amt'] else: # update amount remaining. - _remaining = order['amt'] - amt + _remaining = order['amt'] - excess self.incomplete.loc[self.incomplete['id'] == order['id'], 'amt'] = _remaining self.incomplete.drop(index=_drop, inplace=True) diff --git a/analysis/trend.py b/analysis/trend.py index 2bbdc6c..68e21d4 100644 --- a/analysis/trend.py +++ b/analysis/trend.py @@ -143,9 +143,9 @@ def characterize(self, point: Optional[pd.Timestamp] = None) -> MarketTrend: # remove `freq` value to prevent `KeyError` # TODO: is reusing the name going to affect original `point`? - point = pd.Timestamp.fromtimestamp(point.timestamp(), tz=timezone('US/Pacific')) + _point = pd.Timestamp.fromtimestamp(point.timestamp(), tz=timezone('US/Pacific')) - results = self._fetch_trends(point) + results = self._fetch_trends(_point) consensus = self._determine_consensus(list(results.values())) return MarketTrend(consensus, scalar=self._determine_scalar()) @@ -155,7 +155,7 @@ def _determine_consensus(values: Sequence['TrendMovement']) -> TrendMovement: """ Return the most common value in a dict containing a list of returned results """ counts = {} for v in TrendMovement.__members__.values(): - counts[v] = list(values).count(v) + counts[v] = values.count(v) _max = max(counts.values()) i = list(counts.values()).index(_max) winner = list(counts.keys())[i] diff --git a/strategies/OscillatingStrategy.py b/strategies/OscillatingStrategy.py index 572b952..6fb6468 100644 --- a/strategies/OscillatingStrategy.py +++ b/strategies/OscillatingStrategy.py @@ -34,17 +34,20 @@ def _oscillation(self, signal: Signal, timeout=True) -> bool: Returns: `true` if `signal` decision values. """ - if self.orders.empty: - # force buy for first trade + if self.orders.empty: # first trade must be buy + # TODO: check if `assets` is 0 return signal == Signal.BUY if signal: last_order = self.orders.iloc[-1] - if last_order['side'] == signal == Signal.BUY and self._remaining and timeout: - # Allow repeated buys on timeout + + # prevent more buy orders when there are too many incomplete orders + if self._remaining == 0 and signal == Signal.BUY: + return False + # Allow repeated buys on timeout + elif last_order['side'] == signal == Signal.BUY and self._remaining and timeout: inactive = self._check_timeout() if inactive: - # Add repeated buy to `unpaired_buys` self._handle_inactive(last_order) return inactive return last_order.side != signal @@ -57,12 +60,18 @@ def _determine_position(self, point: pd.Timestamp = None) -> Union[Tuple[Side, ' Oscillation of trade types is executed here. Duplicate trade type is not returned if a new signal is generated. + Number of incomplete (outstanding) orders is limited here. If there are no remaining allowed orders + (as defined by `_remaining`) then False is returned. + Notes: `self.indicators.develop()` needs to be called beforehand. """ if not point: point = self.market.data.iloc[-1].name + if self._remaining <= 1: + pass + signal: Signal = self.indicators.check(self.market.data, point) if self._oscillation(signal): signal: Side = Side(signal) diff --git a/strategies/__init__.py b/strategies/__init__.py index a302fbd..99e631e 100644 --- a/strategies/__init__.py +++ b/strategies/__init__.py @@ -8,13 +8,3 @@ To determine the fitness and performance of the trading strategy, reporting functions can the total amount of assets and fiat accrued. This can be used in active implementations as well as during backtesting. """ - -import strategies.strategy -import strategies.StaticAlternatingStrategy -import strategies.OscillatingStrategy -import strategies.ThreeProngAlt - -from strategies.strategy import Strategy -from strategies.StaticAlternatingStrategy import StaticAlternatingStrategy -from strategies.OscillatingStrategy import OscillatingStrategy -from strategies.ThreeProngAlt import ThreeProngAlt diff --git a/tests/test_analysis_financials.py b/tests/test_analysis_financials.py index 35b2c3d..cda80e1 100644 --- a/tests/test_analysis_financials.py +++ b/tests/test_analysis_financials.py @@ -114,23 +114,35 @@ def test_post_sale(self): def test_handle_inactive(self): self.assertTrue(self.strategy.incomplete.empty) - row = pd.DataFrame({'amt': [5], 'rate': [5], 'id': [5]}) + row = pd.Series({'amt': 5, 'rate': 5, 'id': 5, 'side': Side.BUY}) + self.strategy._handle_inactive(row) - self.assertTrue(row['id'].isin(self.strategy.incomplete['id'])[0]) + self.assertTrue(row['id'] in self.strategy.incomplete['id'].values) # check that values remain after second addition - row2 = pd.DataFrame({'amt': [2], 'rate': [2], 'id': [2]}) + row2 = pd.Series({'amt': 2, 'rate': 2, 'id': 2, 'side': Side.BUY}) self.strategy._handle_inactive(row2) - self.assertTrue(row['id'].isin(self.strategy.incomplete['id'])[0]) - self.assertTrue(row2['id'].isin(self.strategy.incomplete['id'])[0]) + self.assertTrue(row['id'] in self.strategy.incomplete['id'].values) + self.assertTrue(row2['id'] in self.strategy.incomplete['id'].values) self.assertEqual(len(self.strategy.incomplete), 2) + # ============================== # + # assert exceptions and warnings # + + # side must be `BUY + with self.assertRaises(AssertionError): + invalid_row = pd.Series({'amt': 5, 'rate': 5, 'id': 5, 'side': Side.SELL}) + self.strategy._handle_inactive(invalid_row) + + # row must be `Series` with self.assertRaises(AssertionError): - self.strategy._handle_inactive(pd.concat([row, row])) + # noinspection PyTypeChecker + self.strategy._handle_inactive(pd.DataFrame()) + # warn upon adding duplicates with self.assertWarns(Warning): self.strategy._handle_inactive(row)