diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | Makefile | 32 | ||||
-rw-r--r-- | portfolio.py | 98 | ||||
-rw-r--r-- | store.py | 33 | ||||
-rw-r--r-- | test.py | 440 |
5 files changed, 530 insertions, 75 deletions
@@ -1 +1,3 @@ | |||
1 | __pycache__ | 1 | __pycache__ |
2 | .coverage | ||
3 | /htmlcov | ||
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1464886 --- /dev/null +++ b/Makefile | |||
@@ -0,0 +1,32 @@ | |||
1 | test: | ||
2 | python test.py | ||
3 | |||
4 | run: | ||
5 | python portfolio.py | ||
6 | |||
7 | test_coverage_unit: | ||
8 | coverage run --source=. --omit=test.py test.py --onlyunit | ||
9 | coverage report -m | ||
10 | |||
11 | test_coverage_unit_html: test_coverage_unit | ||
12 | coverage html | ||
13 | rm ~/hosts/www.immae.eu/htmlcov -rf && cp -r htmlcov ~/hosts/www.immae.eu | ||
14 | @echo "coverage in https://www.immae.eu/htmlcov" | ||
15 | |||
16 | test_coverage_acceptance: | ||
17 | coverage run --source=. --omit=test.py test.py --onlyacceptance | ||
18 | coverage report -m | ||
19 | |||
20 | test_coverage_acceptance_html: test_coverage_acceptance | ||
21 | coverage html | ||
22 | rm ~/hosts/www.immae.eu/htmlcov -rf && cp -r htmlcov ~/hosts/www.immae.eu | ||
23 | @echo "coverage in https://www.immae.eu/htmlcov" | ||
24 | |||
25 | test_coverage_all: | ||
26 | coverage run --source=. --omit=test.py test.py | ||
27 | coverage report -m | ||
28 | |||
29 | test_coverage_all_html: test_coverage_all | ||
30 | coverage html | ||
31 | rm ~/hosts/www.immae.eu/htmlcov -rf && cp -r htmlcov ~/hosts/www.immae.eu | ||
32 | @echo "coverage in https://www.immae.eu/htmlcov" | ||
diff --git a/portfolio.py b/portfolio.py index efd9b84..b629966 100644 --- a/portfolio.py +++ b/portfolio.py | |||
@@ -149,7 +149,7 @@ class Amount: | |||
149 | 149 | ||
150 | def __floordiv__(self, value): | 150 | def __floordiv__(self, value): |
151 | if not isinstance(value, (int, float, D)): | 151 | if not isinstance(value, (int, float, D)): |
152 | raise TypeError("Amount may only be multiplied by integers") | 152 | raise TypeError("Amount may only be divided by numbers") |
153 | return Amount(self.currency, self.value / value) | 153 | return Amount(self.currency, self.value / value) |
154 | 154 | ||
155 | def __truediv__(self, value): | 155 | def __truediv__(self, value): |
@@ -290,11 +290,10 @@ class Trade: | |||
290 | else: | 290 | else: |
291 | return "long" | 291 | return "long" |
292 | 292 | ||
293 | @property | 293 | def filled_amount(self, in_base_currency=False): |
294 | def filled_amount(self): | ||
295 | filled_amount = 0 | 294 | filled_amount = 0 |
296 | for order in self.orders: | 295 | for order in self.orders: |
297 | filled_amount += order.filled_amount | 296 | filled_amount += order.filled_amount(in_base_currency=in_base_currency) |
298 | return filled_amount | 297 | return filled_amount |
299 | 298 | ||
300 | def update_order(self, order, tick): | 299 | def update_order(self, order, tick): |
@@ -329,54 +328,69 @@ class Trade: | |||
329 | if inverted: | 328 | if inverted: |
330 | ticker = ticker["original"] | 329 | ticker = ticker["original"] |
331 | rate = Computation.compute_value(ticker, self.order_action(inverted), compute_value=compute_value) | 330 | rate = Computation.compute_value(ticker, self.order_action(inverted), compute_value=compute_value) |
332 | # 0.1 | ||
333 | 331 | ||
334 | delta_in_base = abs(self.value_from - self.value_to) | 332 | delta_in_base = abs(self.value_from - self.value_to) |
335 | # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case) | 333 | # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case) |
336 | 334 | ||
337 | if not inverted: | 335 | if not inverted: |
338 | currency = self.base_currency | 336 | base_currency = self.base_currency |
339 | # BTC | 337 | # BTC |
340 | if self.action == "dispose": | 338 | if self.action == "dispose": |
341 | # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it | 339 | filled = self.filled_amount(in_base_currency=False) |
342 | # At rate 1 Foo = 0.1 BTC | 340 | delta = delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate) |
343 | value_from = self.value_from.linked_to | 341 | # I have 10 BTC worth of FOO, and I want to sell 9 BTC |
344 | # value_from = 100 FOO | 342 | # worth of it, computed first with rate 10 FOO = 1 BTC. |
345 | value_to = self.value_to.in_currency(self.currency, self.market, rate=1/self.value_from.rate) | 343 | # -> I "sell" "90" FOO at proposed rate "rate". |
346 | # value_to = 10 FOO (1 BTC * 1/0.1) | 344 | |
347 | delta = abs(value_to - value_from) | 345 | delta = delta - filled |
348 | # delta = 90 FOO | 346 | # I already sold 60 FOO, 30 left |
349 | # Action: "sell" "90 FOO" at rate "0.1" "BTC" on "market" | ||
350 | |||
351 | # Note: no rounding error possible: if we have value_to == 0, then delta == value_from | ||
352 | else: | 347 | else: |
353 | delta = delta_in_base.in_currency(self.currency, self.market, rate=1/rate) | 348 | filled = self.filled_amount(in_base_currency=True) |
354 | # I want to buy 9 / 0.1 FOO | 349 | delta = (delta_in_base - filled).in_currency(self.currency, self.market, rate=1/rate) |
355 | # Action: "buy" "90 FOO" at rate "0.1" "BTC" on "market" | 350 | # I want to buy 9 BTC worth of FOO, computed with rate |
351 | # 10 FOO = 1 BTC | ||
352 | # -> I "buy" "9 / rate" FOO at proposed rate "rate" | ||
353 | |||
354 | # I already bought 3 / rate FOO, 6 / rate left | ||
356 | else: | 355 | else: |
357 | currency = self.currency | 356 | base_currency = self.currency |
358 | # FOO | 357 | # FOO |
359 | delta = delta_in_base | 358 | if self.action == "dispose": |
360 | # sell: | 359 | filled = self.filled_amount(in_base_currency=True) |
361 | # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it | 360 | # Base is FOO |
362 | # At rate 1 Foo = 0.1 BTC | 361 | |
363 | # Action: "buy" "9 BTC" at rate "1/0.1" "FOO" on market | 362 | delta = (delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate) |
364 | # buy: | 363 | - filled).in_currency(self.base_currency, self.market, rate=1/rate) |
365 | # I want to buy 9 / 0.1 FOO | 364 | # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it |
366 | # Action: "sell" "9 BTC" at rate "1/0.1" "FOO" on "market" | 365 | # computed at rate 1 Foo = 0.01 BTC |
367 | if self.value_to == 0: | 366 | # Computation says I should sell it at 125 FOO / BTC |
368 | rate = self.value_from.linked_to.value / self.value_from.value | 367 | # -> delta_in_base = 9 BTC |
369 | # Recompute the rate to avoid any rounding error | 368 | # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC |
369 | # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market | ||
370 | |||
371 | # I already bought 300/125 BTC, only 600/125 left | ||
372 | else: | ||
373 | filled = self.filled_amount(in_base_currency=False) | ||
374 | # Base is FOO | ||
375 | |||
376 | delta = delta_in_base | ||
377 | # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it | ||
378 | # At rate 100 Foo / BTC | ||
379 | # Computation says I should buy it at 125 FOO / BTC | ||
380 | # -> delta_in_base = 9 BTC | ||
381 | # Action: "sell" "9 BTC" at rate "125" "FOO" on market | ||
382 | |||
383 | delta = delta - filled | ||
384 | # I already sold 4 BTC, only 5 left | ||
370 | 385 | ||
371 | close_if_possible = (self.value_to == 0) | 386 | close_if_possible = (self.value_to == 0) |
372 | 387 | ||
373 | if delta <= self.filled_amount: | 388 | if delta <= 0: |
374 | print("Less to do than already filled: {} <= {}".format(delta, | 389 | print("Less to do than already filled: {}".format(delta)) |
375 | self.filled_amount)) | ||
376 | return | 390 | return |
377 | 391 | ||
378 | self.orders.append(Order(self.order_action(inverted), | 392 | self.orders.append(Order(self.order_action(inverted), |
379 | delta - self.filled_amount, rate, currency, self.trade_type, | 393 | delta, rate, base_currency, self.trade_type, |
380 | self.market, self, close_if_possible=close_if_possible)) | 394 | self.market, self, close_if_possible=close_if_possible)) |
381 | 395 | ||
382 | def __repr__(self): | 396 | def __repr__(self): |
@@ -497,15 +511,17 @@ class Order: | |||
497 | def remaining_amount(self): | 511 | def remaining_amount(self): |
498 | if self.status == "open": | 512 | if self.status == "open": |
499 | self.fetch() | 513 | self.fetch() |
500 | return self.amount - self.filled_amount | 514 | return self.amount - self.filled_amount() |
501 | 515 | ||
502 | @property | 516 | def filled_amount(self, in_base_currency=False): |
503 | def filled_amount(self): | ||
504 | if self.status == "open": | 517 | if self.status == "open": |
505 | self.fetch() | 518 | self.fetch() |
506 | filled_amount = Amount(self.amount.currency, 0) | 519 | filled_amount = 0 |
507 | for mouvement in self.mouvements: | 520 | for mouvement in self.mouvements: |
508 | filled_amount += mouvement.total | 521 | if in_base_currency: |
522 | filled_amount += mouvement.total_in_base | ||
523 | else: | ||
524 | filled_amount += mouvement.total | ||
509 | return filled_amount | 525 | return filled_amount |
510 | 526 | ||
511 | def fetch_mouvements(self): | 527 | def fetch_mouvements(self): |
@@ -53,22 +53,27 @@ class TradeStore: | |||
53 | continue | 53 | continue |
54 | value_from = values_in_base.get(currency, portfolio.Amount(base_currency, 0)) | 54 | value_from = values_in_base.get(currency, portfolio.Amount(base_currency, 0)) |
55 | value_to = new_repartition.get(currency, portfolio.Amount(base_currency, 0)) | 55 | value_to = new_repartition.get(currency, portfolio.Amount(base_currency, 0)) |
56 | |||
56 | if value_from.value * value_to.value < 0: | 57 | if value_from.value * value_to.value < 0: |
57 | trade_1 = portfolio.Trade(value_from, portfolio.Amount(base_currency, 0), currency, market=market) | 58 | cls.add_trade_if_matching( |
58 | if only is None or trade_1.action == only: | 59 | value_from, portfolio.Amount(base_currency, 0), |
59 | cls.all.append(trade_1) | 60 | currency, only=only, market=market) |
60 | trade_2 = portfolio.Trade(portfolio.Amount(base_currency, 0), value_to, currency, market=market) | 61 | cls.add_trade_if_matching( |
61 | if only is None or trade_2.action == only: | 62 | portfolio.Amount(base_currency, 0), value_to, |
62 | cls.all.append(trade_2) | 63 | currency, only=only, market=market) |
63 | else: | 64 | else: |
64 | trade = portfolio.Trade( | 65 | cls.add_trade_if_matching(value_from, value_to, |
65 | value_from, | 66 | currency, only=only, market=market) |
66 | value_to, | 67 | |
67 | currency, | 68 | @classmethod |
68 | market=market | 69 | def add_trade_if_matching(cls, value_from, value_to, currency, |
69 | ) | 70 | only=None, market=None): |
70 | if only is None or trade.action == only: | 71 | trade = portfolio.Trade(value_from, value_to, currency, |
71 | cls.all.append(trade) | 72 | market=market) |
73 | if only is None or trade.action == only: | ||
74 | cls.all.append(trade) | ||
75 | return True | ||
76 | return False | ||
72 | 77 | ||
73 | @classmethod | 78 | @classmethod |
74 | def prepare_orders(cls, only=None, compute_value="default"): | 79 | def prepare_orders(cls, only=None, compute_value="default"): |
@@ -1,3 +1,4 @@ | |||
1 | import sys | ||
1 | import portfolio | 2 | import portfolio |
2 | import unittest | 3 | import unittest |
3 | from decimal import Decimal as D | 4 | from decimal import Decimal as D |
@@ -7,6 +8,16 @@ import requests_mock | |||
7 | from io import StringIO | 8 | from io import StringIO |
8 | import helper | 9 | import helper |
9 | 10 | ||
11 | limits = ["acceptance", "unit"] | ||
12 | for test_type in limits: | ||
13 | if "--no{}".format(test_type) in sys.argv: | ||
14 | sys.argv.remove("--no{}".format(test_type)) | ||
15 | limits.remove(test_type) | ||
16 | if "--only{}".format(test_type) in sys.argv: | ||
17 | sys.argv.remove("--only{}".format(test_type)) | ||
18 | limits = [test_type] | ||
19 | break | ||
20 | |||
10 | class WebMockTestCase(unittest.TestCase): | 21 | class WebMockTestCase(unittest.TestCase): |
11 | import time | 22 | import time |
12 | 23 | ||
@@ -39,6 +50,7 @@ class WebMockTestCase(unittest.TestCase): | |||
39 | self.wm.stop() | 50 | self.wm.stop() |
40 | super(WebMockTestCase, self).tearDown() | 51 | super(WebMockTestCase, self).tearDown() |
41 | 52 | ||
53 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
42 | class PortfolioTest(WebMockTestCase): | 54 | class PortfolioTest(WebMockTestCase): |
43 | def fill_data(self): | 55 | def fill_data(self): |
44 | if self.json_response is not None: | 56 | if self.json_response is not None: |
@@ -140,6 +152,7 @@ class PortfolioTest(WebMockTestCase): | |||
140 | self.assertEqual(expected_medium, portfolio.Portfolio.repartition(liquidity="medium")) | 152 | self.assertEqual(expected_medium, portfolio.Portfolio.repartition(liquidity="medium")) |
141 | self.assertEqual(expected_high, portfolio.Portfolio.repartition(liquidity="high")) | 153 | self.assertEqual(expected_high, portfolio.Portfolio.repartition(liquidity="high")) |
142 | 154 | ||
155 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
143 | class AmountTest(WebMockTestCase): | 156 | class AmountTest(WebMockTestCase): |
144 | def test_values(self): | 157 | def test_values(self): |
145 | amount = portfolio.Amount("BTC", "0.65") | 158 | amount = portfolio.Amount("BTC", "0.65") |
@@ -250,6 +263,9 @@ class AmountTest(WebMockTestCase): | |||
250 | self.assertEqual(D("5.5"), (amount / 2).value) | 263 | self.assertEqual(D("5.5"), (amount / 2).value) |
251 | self.assertEqual(D("4.4"), (amount / D("2.5")).value) | 264 | self.assertEqual(D("4.4"), (amount / D("2.5")).value) |
252 | 265 | ||
266 | with self.assertRaises(Exception): | ||
267 | amount / amount | ||
268 | |||
253 | def test__truediv(self): | 269 | def test__truediv(self): |
254 | amount = portfolio.Amount("XEM", 11) | 270 | amount = portfolio.Amount("XEM", 11) |
255 | 271 | ||
@@ -363,6 +379,7 @@ class AmountTest(WebMockTestCase): | |||
363 | amount2.linked_to = amount3 | 379 | amount2.linked_to = amount3 |
364 | self.assertEqual("Amount(32.00000000 BTX -> Amount(12000.00000000 USDT -> Amount(0.10000000 BTC)))", repr(amount1)) | 380 | self.assertEqual("Amount(32.00000000 BTX -> Amount(12000.00000000 USDT -> Amount(0.10000000 BTC)))", repr(amount1)) |
365 | 381 | ||
382 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
366 | class BalanceTest(WebMockTestCase): | 383 | class BalanceTest(WebMockTestCase): |
367 | def test_values(self): | 384 | def test_values(self): |
368 | balance = portfolio.Balance("BTC", { | 385 | balance = portfolio.Balance("BTC", { |
@@ -405,16 +422,27 @@ class BalanceTest(WebMockTestCase): | |||
405 | "exchange_used": 1, "exchange_free": 2 }) | 422 | "exchange_used": 1, "exchange_free": 2 }) |
406 | self.assertEqual("Balance(BTX Exch: [✔2.00000000 BTX + ❌1.00000000 BTX = 3.00000000 BTX])", repr(balance)) | 423 | self.assertEqual("Balance(BTX Exch: [✔2.00000000 BTX + ❌1.00000000 BTX = 3.00000000 BTX])", repr(balance)) |
407 | 424 | ||
425 | balance = portfolio.Balance("BTX", { "exchange_total": 1, "exchange_used": 1}) | ||
426 | self.assertEqual("Balance(BTX Exch: [❌1.00000000 BTX])", repr(balance)) | ||
427 | |||
408 | balance = portfolio.Balance("BTX", { "margin_total": 3, | 428 | balance = portfolio.Balance("BTX", { "margin_total": 3, |
409 | "margin_borrowed": 1, "margin_free": 2 }) | 429 | "margin_borrowed": 1, "margin_free": 2 }) |
410 | self.assertEqual("Balance(BTX Margin: [✔2.00000000 BTX + borrowed 1.00000000 BTX = 3.00000000 BTX])", repr(balance)) | 430 | self.assertEqual("Balance(BTX Margin: [✔2.00000000 BTX + borrowed 1.00000000 BTX = 3.00000000 BTX])", repr(balance)) |
411 | 431 | ||
432 | balance = portfolio.Balance("BTX", { "margin_total": 2, "margin_free": 2 }) | ||
433 | self.assertEqual("Balance(BTX Margin: [✔2.00000000 BTX])", repr(balance)) | ||
434 | |||
412 | balance = portfolio.Balance("BTX", { "margin_total": -3, | 435 | balance = portfolio.Balance("BTX", { "margin_total": -3, |
413 | "margin_borrowed_base_price": D("0.1"), | 436 | "margin_borrowed_base_price": D("0.1"), |
414 | "margin_borrowed_base_currency": "BTC", | 437 | "margin_borrowed_base_currency": "BTC", |
415 | "margin_lending_fees": D("0.002") }) | 438 | "margin_lending_fees": D("0.002") }) |
416 | self.assertEqual("Balance(BTX Margin: [-3.00000000 BTX @@ 0.10000000 BTC/0.00200000 BTC])", repr(balance)) | 439 | self.assertEqual("Balance(BTX Margin: [-3.00000000 BTX @@ 0.10000000 BTC/0.00200000 BTC])", repr(balance)) |
417 | 440 | ||
441 | balance = portfolio.Balance("BTX", { "margin_total": 1, | ||
442 | "margin_borrowed": 1, "exchange_free": 2, "exchange_total": 2}) | ||
443 | self.assertEqual("Balance(BTX Exch: [✔2.00000000 BTX] Margin: [borrowed 1.00000000 BTX] Total: [0.00000000 BTX])", repr(balance)) | ||
444 | |||
445 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
418 | class HelperTest(WebMockTestCase): | 446 | class HelperTest(WebMockTestCase): |
419 | def test_get_ticker(self): | 447 | def test_get_ticker(self): |
420 | market = mock.Mock() | 448 | market = mock.Mock() |
@@ -612,15 +640,153 @@ class HelperTest(WebMockTestCase): | |||
612 | self.assertEqual(D("0.01"), call[0][0]["XVG"].value) | 640 | self.assertEqual(D("0.01"), call[0][0]["XVG"].value) |
613 | self.assertEqual(D("1.01"), call[0][1]["BTC"].value) | 641 | self.assertEqual(D("1.01"), call[0][1]["BTC"].value) |
614 | 642 | ||
615 | @unittest.skip("TODO") | 643 | @mock.patch.object(portfolio.time, "sleep") |
616 | def test_follow_orders(self): | 644 | @mock.patch.object(portfolio.TradeStore, "all_orders") |
617 | pass | 645 | def test_follow_orders(self, all_orders, time_mock): |
618 | 646 | for verbose, debug, sleep in [ | |
619 | 647 | (True, False, None), (False, False, None), | |
648 | (True, True, None), (True, False, 12), | ||
649 | (True, True, 12)]: | ||
650 | with self.subTest(sleep=sleep, debug=debug, verbose=verbose), \ | ||
651 | mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
652 | portfolio.TradeStore.debug = debug | ||
653 | order_mock1 = mock.Mock() | ||
654 | order_mock2 = mock.Mock() | ||
655 | order_mock3 = mock.Mock() | ||
656 | all_orders.side_effect = [ | ||
657 | [order_mock1, order_mock2], | ||
658 | [order_mock1, order_mock2], | ||
659 | |||
660 | [order_mock1, order_mock3], | ||
661 | [order_mock1, order_mock3], | ||
662 | |||
663 | [order_mock1, order_mock3], | ||
664 | [order_mock1, order_mock3], | ||
665 | |||
666 | [] | ||
667 | ] | ||
668 | |||
669 | order_mock1.get_status.side_effect = ["open", "open", "closed"] | ||
670 | order_mock2.get_status.side_effect = ["open"] | ||
671 | order_mock3.get_status.side_effect = ["open", "closed"] | ||
672 | |||
673 | order_mock1.trade = mock.Mock() | ||
674 | order_mock2.trade = mock.Mock() | ||
675 | order_mock3.trade = mock.Mock() | ||
676 | |||
677 | helper.follow_orders(verbose=verbose, sleep=sleep) | ||
678 | |||
679 | order_mock1.trade.update_order.assert_any_call(order_mock1, 1) | ||
680 | order_mock1.trade.update_order.assert_any_call(order_mock1, 2) | ||
681 | self.assertEqual(2, order_mock1.trade.update_order.call_count) | ||
682 | self.assertEqual(3, order_mock1.get_status.call_count) | ||
683 | |||
684 | order_mock2.trade.update_order.assert_any_call(order_mock2, 1) | ||
685 | self.assertEqual(1, order_mock2.trade.update_order.call_count) | ||
686 | self.assertEqual(1, order_mock2.get_status.call_count) | ||
687 | |||
688 | order_mock3.trade.update_order.assert_any_call(order_mock3, 2) | ||
689 | self.assertEqual(1, order_mock3.trade.update_order.call_count) | ||
690 | self.assertEqual(2, order_mock3.get_status.call_count) | ||
691 | |||
692 | if sleep is None: | ||
693 | if debug: | ||
694 | time_mock.assert_called_with(7) | ||
695 | else: | ||
696 | time_mock.assert_called_with(30) | ||
697 | else: | ||
698 | time_mock.assert_called_with(sleep) | ||
699 | |||
700 | if verbose: | ||
701 | self.assertNotEqual("", stdout_mock.getvalue()) | ||
702 | else: | ||
703 | self.assertEqual("", stdout_mock.getvalue()) | ||
704 | |||
705 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
620 | class TradeStoreTest(WebMockTestCase): | 706 | class TradeStoreTest(WebMockTestCase): |
621 | @unittest.skip("TODO") | 707 | @mock.patch.object(portfolio.BalanceStore, "currencies") |
622 | def test_compute_trades(self): | 708 | @mock.patch.object(portfolio.TradeStore, "add_trade_if_matching") |
623 | pass | 709 | def test_compute_trades(self, add_trade_if_matching, currencies): |
710 | currencies.return_value = ["XMR", "DASH", "XVG", "BTC", "ETH"] | ||
711 | |||
712 | values_in_base = { | ||
713 | "XMR": portfolio.Amount("BTC", D("0.9")), | ||
714 | "DASH": portfolio.Amount("BTC", D("0.4")), | ||
715 | "XVG": portfolio.Amount("BTC", D("-0.5")), | ||
716 | "BTC": portfolio.Amount("BTC", D("0.5")), | ||
717 | } | ||
718 | new_repartition = { | ||
719 | "DASH": portfolio.Amount("BTC", D("0.5")), | ||
720 | "XVG": portfolio.Amount("BTC", D("0.1")), | ||
721 | "BTC": portfolio.Amount("BTC", D("0.4")), | ||
722 | "ETH": portfolio.Amount("BTC", D("0.3")), | ||
723 | } | ||
724 | |||
725 | portfolio.TradeStore.compute_trades(values_in_base, | ||
726 | new_repartition, only="only", market="market") | ||
727 | |||
728 | self.assertEqual(5, add_trade_if_matching.call_count) | ||
729 | add_trade_if_matching.assert_any_call( | ||
730 | portfolio.Amount("BTC", D("0.9")), | ||
731 | portfolio.Amount("BTC", 0), | ||
732 | "XMR", only="only", market="market" | ||
733 | ) | ||
734 | add_trade_if_matching.assert_any_call( | ||
735 | portfolio.Amount("BTC", D("0.4")), | ||
736 | portfolio.Amount("BTC", D("0.5")), | ||
737 | "DASH", only="only", market="market" | ||
738 | ) | ||
739 | add_trade_if_matching.assert_any_call( | ||
740 | portfolio.Amount("BTC", D("-0.5")), | ||
741 | portfolio.Amount("BTC", D("0")), | ||
742 | "XVG", only="only", market="market" | ||
743 | ) | ||
744 | add_trade_if_matching.assert_any_call( | ||
745 | portfolio.Amount("BTC", D("0")), | ||
746 | portfolio.Amount("BTC", D("0.1")), | ||
747 | "XVG", only="only", market="market" | ||
748 | ) | ||
749 | add_trade_if_matching.assert_any_call( | ||
750 | portfolio.Amount("BTC", D("0")), | ||
751 | portfolio.Amount("BTC", D("0.3")), | ||
752 | "ETH", only="only", market="market" | ||
753 | ) | ||
754 | |||
755 | def test_add_trade_if_matching(self): | ||
756 | result = portfolio.TradeStore.add_trade_if_matching( | ||
757 | portfolio.Amount("BTC", D("0")), | ||
758 | portfolio.Amount("BTC", D("0.3")), | ||
759 | "ETH", only="nope", market="market" | ||
760 | ) | ||
761 | self.assertEqual(0, len(portfolio.TradeStore.all)) | ||
762 | self.assertEqual(False, result) | ||
763 | |||
764 | portfolio.TradeStore.all = [] | ||
765 | result = portfolio.TradeStore.add_trade_if_matching( | ||
766 | portfolio.Amount("BTC", D("0")), | ||
767 | portfolio.Amount("BTC", D("0.3")), | ||
768 | "ETH", only=None, market="market" | ||
769 | ) | ||
770 | self.assertEqual(1, len(portfolio.TradeStore.all)) | ||
771 | self.assertEqual(True, result) | ||
772 | |||
773 | portfolio.TradeStore.all = [] | ||
774 | result = portfolio.TradeStore.add_trade_if_matching( | ||
775 | portfolio.Amount("BTC", D("0")), | ||
776 | portfolio.Amount("BTC", D("0.3")), | ||
777 | "ETH", only="acquire", market="market" | ||
778 | ) | ||
779 | self.assertEqual(1, len(portfolio.TradeStore.all)) | ||
780 | self.assertEqual(True, result) | ||
781 | |||
782 | portfolio.TradeStore.all = [] | ||
783 | result = portfolio.TradeStore.add_trade_if_matching( | ||
784 | portfolio.Amount("BTC", D("0")), | ||
785 | portfolio.Amount("BTC", D("0.3")), | ||
786 | "ETH", only="dispose", market="market" | ||
787 | ) | ||
788 | self.assertEqual(0, len(portfolio.TradeStore.all)) | ||
789 | self.assertEqual(False, result) | ||
624 | 790 | ||
625 | def test_prepare_orders(self): | 791 | def test_prepare_orders(self): |
626 | trade_mock1 = mock.Mock() | 792 | trade_mock1 = mock.Mock() |
@@ -709,7 +875,7 @@ class TradeStoreTest(WebMockTestCase): | |||
709 | order_mock2.get_status.assert_called() | 875 | order_mock2.get_status.assert_called() |
710 | order_mock3.get_status.assert_called() | 876 | order_mock3.get_status.assert_called() |
711 | 877 | ||
712 | 878 | @unittest.skipUnless("unit" in limits, "Unit skipped") | |
713 | class BalanceStoreTest(WebMockTestCase): | 879 | class BalanceStoreTest(WebMockTestCase): |
714 | def setUp(self): | 880 | def setUp(self): |
715 | super(BalanceStoreTest, self).setUp() | 881 | super(BalanceStoreTest, self).setUp() |
@@ -802,12 +968,14 @@ class BalanceStoreTest(WebMockTestCase): | |||
802 | repartition.return_value = { | 968 | repartition.return_value = { |
803 | "XEM": (D("0.75"), "long"), | 969 | "XEM": (D("0.75"), "long"), |
804 | "BTC": (D("0.26"), "long"), | 970 | "BTC": (D("0.26"), "long"), |
971 | "DASH": (D("0.10"), "short"), | ||
805 | } | 972 | } |
806 | 973 | ||
807 | amounts = portfolio.BalanceStore.dispatch_assets(portfolio.Amount("BTC", "10.1")) | 974 | amounts = portfolio.BalanceStore.dispatch_assets(portfolio.Amount("BTC", "11.1")) |
808 | self.assertIn("XEM", portfolio.BalanceStore.currencies()) | 975 | self.assertIn("XEM", portfolio.BalanceStore.currencies()) |
809 | self.assertEqual(D("2.6"), amounts["BTC"].value) | 976 | self.assertEqual(D("2.6"), amounts["BTC"].value) |
810 | self.assertEqual(D("7.5"), amounts["XEM"].value) | 977 | self.assertEqual(D("7.5"), amounts["XEM"].value) |
978 | self.assertEqual(D("-1.0"), amounts["DASH"].value) | ||
811 | 979 | ||
812 | def test_currencies(self): | 980 | def test_currencies(self): |
813 | portfolio.BalanceStore.all = { | 981 | portfolio.BalanceStore.all = { |
@@ -824,6 +992,7 @@ class BalanceStoreTest(WebMockTestCase): | |||
824 | } | 992 | } |
825 | self.assertListEqual(["BTC", "ETH"], list(portfolio.BalanceStore.currencies())) | 993 | self.assertListEqual(["BTC", "ETH"], list(portfolio.BalanceStore.currencies())) |
826 | 994 | ||
995 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
827 | class ComputationTest(WebMockTestCase): | 996 | class ComputationTest(WebMockTestCase): |
828 | def test_compute_value(self): | 997 | def test_compute_value(self): |
829 | compute = mock.Mock() | 998 | compute = mock.Mock() |
@@ -848,6 +1017,7 @@ class ComputationTest(WebMockTestCase): | |||
848 | compute.assert_called_with("foo", "bid") | 1017 | compute.assert_called_with("foo", "bid") |
849 | 1018 | ||
850 | 1019 | ||
1020 | @unittest.skipUnless("unit" in limits, "Unit skipped") | ||
851 | class TradeTest(WebMockTestCase): | 1021 | class TradeTest(WebMockTestCase): |
852 | 1022 | ||
853 | def test_values_assertion(self): | 1023 | def test_values_assertion(self): |
@@ -881,7 +1051,7 @@ class TradeTest(WebMockTestCase): | |||
881 | 1051 | ||
882 | value_from = portfolio.Amount("BTC", "1.0") | 1052 | value_from = portfolio.Amount("BTC", "1.0") |
883 | value_from.linked_to = portfolio.Amount("BTC", "1.0") | 1053 | value_from.linked_to = portfolio.Amount("BTC", "1.0") |
884 | value_to = portfolio.Amount("BTC", "1.0") | 1054 | value_to = portfolio.Amount("BTC", "2.0") |
885 | trade = portfolio.Trade(value_from, value_to, "BTC") | 1055 | trade = portfolio.Trade(value_from, value_to, "BTC") |
886 | 1056 | ||
887 | self.assertIsNone(trade.action) | 1057 | self.assertIsNone(trade.action) |
@@ -939,22 +1109,251 @@ class TradeTest(WebMockTestCase): | |||
939 | trade = portfolio.Trade(value_from, value_to, "ETH") | 1109 | trade = portfolio.Trade(value_from, value_to, "ETH") |
940 | 1110 | ||
941 | order1 = mock.Mock() | 1111 | order1 = mock.Mock() |
942 | order1.filled_amount = portfolio.Amount("ETH", "0.3") | 1112 | order1.filled_amount.return_value = portfolio.Amount("ETH", "0.3") |
943 | 1113 | ||
944 | order2 = mock.Mock() | 1114 | order2 = mock.Mock() |
945 | order2.filled_amount = portfolio.Amount("ETH", "0.01") | 1115 | order2.filled_amount.return_value = portfolio.Amount("ETH", "0.01") |
946 | trade.orders.append(order1) | 1116 | trade.orders.append(order1) |
947 | trade.orders.append(order2) | 1117 | trade.orders.append(order2) |
948 | 1118 | ||
949 | self.assertEqual(portfolio.Amount("ETH", "0.31"), trade.filled_amount) | 1119 | self.assertEqual(portfolio.Amount("ETH", "0.31"), trade.filled_amount()) |
1120 | order1.filled_amount.assert_called_with(in_base_currency=False) | ||
1121 | order2.filled_amount.assert_called_with(in_base_currency=False) | ||
950 | 1122 | ||
951 | @unittest.skip("TODO") | 1123 | self.assertEqual(portfolio.Amount("ETH", "0.31"), trade.filled_amount(in_base_currency=False)) |
952 | def test_prepare_order(self): | 1124 | order1.filled_amount.assert_called_with(in_base_currency=False) |
953 | pass | 1125 | order2.filled_amount.assert_called_with(in_base_currency=False) |
1126 | |||
1127 | self.assertEqual(portfolio.Amount("ETH", "0.31"), trade.filled_amount(in_base_currency=True)) | ||
1128 | order1.filled_amount.assert_called_with(in_base_currency=True) | ||
1129 | order2.filled_amount.assert_called_with(in_base_currency=True) | ||
1130 | |||
1131 | @mock.patch.object(helper, "get_ticker") | ||
1132 | @mock.patch.object(portfolio.Computation, "compute_value") | ||
1133 | @mock.patch.object(portfolio.Trade, "filled_amount") | ||
1134 | @mock.patch.object(portfolio, "Order") | ||
1135 | def test_prepare_order(self, Order, filled_amount, compute_value, get_ticker): | ||
1136 | Order.return_value = "Order" | ||
1137 | |||
1138 | with self.subTest(desc="Nothing to do"): | ||
1139 | value_from = portfolio.Amount("BTC", "10") | ||
1140 | value_from.rate = D("0.1") | ||
1141 | value_from.linked_to = portfolio.Amount("FOO", "100") | ||
1142 | value_to = portfolio.Amount("BTC", "10") | ||
1143 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1144 | |||
1145 | trade.prepare_order() | ||
1146 | |||
1147 | filled_amount.assert_not_called() | ||
1148 | compute_value.assert_not_called() | ||
1149 | self.assertEqual(0, len(trade.orders)) | ||
1150 | Order.assert_not_called() | ||
1151 | |||
1152 | get_ticker.return_value = { "inverted": False } | ||
1153 | with self.subTest(desc="Already filled"), mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1154 | filled_amount.return_value = portfolio.Amount("FOO", "100") | ||
1155 | compute_value.return_value = D("0.125") | ||
1156 | |||
1157 | value_from = portfolio.Amount("BTC", "10") | ||
1158 | value_from.rate = D("0.1") | ||
1159 | value_from.linked_to = portfolio.Amount("FOO", "100") | ||
1160 | value_to = portfolio.Amount("BTC", "0") | ||
1161 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1162 | |||
1163 | trade.prepare_order() | ||
1164 | |||
1165 | filled_amount.assert_called_with(in_base_currency=False) | ||
1166 | compute_value.assert_called_with(get_ticker.return_value, "sell", compute_value="default") | ||
1167 | self.assertEqual(0, len(trade.orders)) | ||
1168 | self.assertRegex(stdout_mock.getvalue(), "Less to do than already filled: ") | ||
1169 | Order.assert_not_called() | ||
1170 | |||
1171 | with self.subTest(action="dispose", inverted=False): | ||
1172 | filled_amount.return_value = portfolio.Amount("FOO", "60") | ||
1173 | compute_value.return_value = D("0.125") | ||
1174 | |||
1175 | value_from = portfolio.Amount("BTC", "10") | ||
1176 | value_from.rate = D("0.1") | ||
1177 | value_from.linked_to = portfolio.Amount("FOO", "100") | ||
1178 | value_to = portfolio.Amount("BTC", "1") | ||
1179 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1180 | |||
1181 | trade.prepare_order() | ||
1182 | |||
1183 | filled_amount.assert_called_with(in_base_currency=False) | ||
1184 | compute_value.assert_called_with(get_ticker.return_value, "sell", compute_value="default") | ||
1185 | self.assertEqual(1, len(trade.orders)) | ||
1186 | Order.assert_called_with("sell", portfolio.Amount("FOO", 30), | ||
1187 | D("0.125"), "BTC", "long", "market", | ||
1188 | trade, close_if_possible=False) | ||
1189 | |||
1190 | with self.subTest(action="acquire", inverted=False): | ||
1191 | filled_amount.return_value = portfolio.Amount("BTC", "3") | ||
1192 | compute_value.return_value = D("0.125") | ||
1193 | |||
1194 | value_from = portfolio.Amount("BTC", "1") | ||
1195 | value_from.rate = D("0.1") | ||
1196 | value_from.linked_to = portfolio.Amount("FOO", "10") | ||
1197 | value_to = portfolio.Amount("BTC", "10") | ||
1198 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1199 | |||
1200 | trade.prepare_order() | ||
1201 | |||
1202 | filled_amount.assert_called_with(in_base_currency=True) | ||
1203 | compute_value.assert_called_with(get_ticker.return_value, "buy", compute_value="default") | ||
1204 | self.assertEqual(1, len(trade.orders)) | ||
1205 | |||
1206 | Order.assert_called_with("buy", portfolio.Amount("FOO", 48), | ||
1207 | D("0.125"), "BTC", "long", "market", | ||
1208 | trade, close_if_possible=False) | ||
1209 | |||
1210 | with self.subTest(close_if_possible=True): | ||
1211 | filled_amount.return_value = portfolio.Amount("FOO", "0") | ||
1212 | compute_value.return_value = D("0.125") | ||
1213 | |||
1214 | value_from = portfolio.Amount("BTC", "10") | ||
1215 | value_from.rate = D("0.1") | ||
1216 | value_from.linked_to = portfolio.Amount("FOO", "100") | ||
1217 | value_to = portfolio.Amount("BTC", "0") | ||
1218 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1219 | |||
1220 | trade.prepare_order() | ||
1221 | |||
1222 | filled_amount.assert_called_with(in_base_currency=False) | ||
1223 | compute_value.assert_called_with(get_ticker.return_value, "sell", compute_value="default") | ||
1224 | self.assertEqual(1, len(trade.orders)) | ||
1225 | Order.assert_called_with("sell", portfolio.Amount("FOO", 100), | ||
1226 | D("0.125"), "BTC", "long", "market", | ||
1227 | trade, close_if_possible=True) | ||
1228 | |||
1229 | get_ticker.return_value = { "inverted": True, "original": {} } | ||
1230 | with self.subTest(action="dispose", inverted=True): | ||
1231 | filled_amount.return_value = portfolio.Amount("FOO", "300") | ||
1232 | compute_value.return_value = D("125") | ||
1233 | |||
1234 | value_from = portfolio.Amount("BTC", "10") | ||
1235 | value_from.rate = D("0.01") | ||
1236 | value_from.linked_to = portfolio.Amount("FOO", "1000") | ||
1237 | value_to = portfolio.Amount("BTC", "1") | ||
1238 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1239 | |||
1240 | trade.prepare_order(compute_value="foo") | ||
1241 | |||
1242 | filled_amount.assert_called_with(in_base_currency=True) | ||
1243 | compute_value.assert_called_with(get_ticker.return_value["original"], "buy", compute_value="foo") | ||
1244 | self.assertEqual(1, len(trade.orders)) | ||
1245 | Order.assert_called_with("buy", portfolio.Amount("BTC", D("4.8")), | ||
1246 | D("125"), "FOO", "long", "market", | ||
1247 | trade, close_if_possible=False) | ||
1248 | |||
1249 | with self.subTest(action="acquire", inverted=True): | ||
1250 | filled_amount.return_value = portfolio.Amount("BTC", "4") | ||
1251 | compute_value.return_value = D("125") | ||
1252 | |||
1253 | value_from = portfolio.Amount("BTC", "1") | ||
1254 | value_from.rate = D("0.01") | ||
1255 | value_from.linked_to = portfolio.Amount("FOO", "100") | ||
1256 | value_to = portfolio.Amount("BTC", "10") | ||
1257 | trade = portfolio.Trade(value_from, value_to, "FOO", market="market") | ||
1258 | |||
1259 | trade.prepare_order(compute_value="foo") | ||
1260 | |||
1261 | filled_amount.assert_called_with(in_base_currency=False) | ||
1262 | compute_value.assert_called_with(get_ticker.return_value["original"], "sell", compute_value="foo") | ||
1263 | self.assertEqual(1, len(trade.orders)) | ||
1264 | Order.assert_called_with("sell", portfolio.Amount("BTC", D("5")), | ||
1265 | D("125"), "FOO", "long", "market", | ||
1266 | trade, close_if_possible=False) | ||
1267 | |||
1268 | |||
1269 | @mock.patch.object(portfolio.Trade, "prepare_order") | ||
1270 | def test_update_order(self, prepare_order): | ||
1271 | order_mock = mock.Mock() | ||
1272 | new_order_mock = mock.Mock() | ||
1273 | |||
1274 | value_from = portfolio.Amount("BTC", "0.5") | ||
1275 | value_from.linked_to = portfolio.Amount("ETH", "10.0") | ||
1276 | value_to = portfolio.Amount("BTC", "1.0") | ||
1277 | trade = portfolio.Trade(value_from, value_to, "ETH") | ||
1278 | def _prepare_order(compute_value=None): | ||
1279 | trade.orders.append(new_order_mock) | ||
1280 | prepare_order.side_effect = _prepare_order | ||
1281 | |||
1282 | for i in [0, 1, 3, 4, 6]: | ||
1283 | with self.subTest(tick=i), mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1284 | trade.update_order(order_mock, i) | ||
1285 | order_mock.cancel.assert_not_called() | ||
1286 | new_order_mock.run.assert_not_called() | ||
1287 | self.assertRegex(stdout_mock.getvalue(), "tick {}, waiting".format(i)) | ||
1288 | self.assertEqual(0, len(trade.orders)) | ||
1289 | |||
1290 | order_mock.reset_mock() | ||
1291 | new_order_mock.reset_mock() | ||
1292 | trade.orders = [] | ||
1293 | |||
1294 | with mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1295 | trade.update_order(order_mock, 2) | ||
1296 | order_mock.cancel.assert_called() | ||
1297 | new_order_mock.run.assert_called() | ||
1298 | prepare_order.assert_called() | ||
1299 | self.assertRegex(stdout_mock.getvalue(), "tick 2, cancelling and adjusting") | ||
1300 | self.assertEqual(1, len(trade.orders)) | ||
1301 | |||
1302 | order_mock.reset_mock() | ||
1303 | new_order_mock.reset_mock() | ||
1304 | trade.orders = [] | ||
1305 | |||
1306 | with mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1307 | trade.update_order(order_mock, 5) | ||
1308 | order_mock.cancel.assert_called() | ||
1309 | new_order_mock.run.assert_called() | ||
1310 | prepare_order.assert_called() | ||
1311 | self.assertRegex(stdout_mock.getvalue(), "tick 5, cancelling and adjusting") | ||
1312 | self.assertEqual(1, len(trade.orders)) | ||
1313 | |||
1314 | order_mock.reset_mock() | ||
1315 | new_order_mock.reset_mock() | ||
1316 | trade.orders = [] | ||
1317 | |||
1318 | with mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1319 | trade.update_order(order_mock, 7) | ||
1320 | order_mock.cancel.assert_called() | ||
1321 | new_order_mock.run.assert_called() | ||
1322 | prepare_order.assert_called_with(compute_value="default") | ||
1323 | self.assertRegex(stdout_mock.getvalue(), "tick 7, fallbacking to market value") | ||
1324 | self.assertRegex(stdout_mock.getvalue(), "tick 7, market value, cancelling and adjusting to") | ||
1325 | self.assertEqual(1, len(trade.orders)) | ||
1326 | |||
1327 | order_mock.reset_mock() | ||
1328 | new_order_mock.reset_mock() | ||
1329 | trade.orders = [] | ||
1330 | |||
1331 | for i in [10, 13, 16]: | ||
1332 | with self.subTest(tick=i), mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1333 | trade.update_order(order_mock, i) | ||
1334 | order_mock.cancel.assert_called() | ||
1335 | new_order_mock.run.assert_called() | ||
1336 | prepare_order.assert_called_with(compute_value="default") | ||
1337 | self.assertNotRegex(stdout_mock.getvalue(), "tick {}, fallbacking to market value".format(i)) | ||
1338 | self.assertRegex(stdout_mock.getvalue(), "tick {}, market value, cancelling and adjusting to".format(i)) | ||
1339 | self.assertEqual(1, len(trade.orders)) | ||
1340 | |||
1341 | order_mock.reset_mock() | ||
1342 | new_order_mock.reset_mock() | ||
1343 | trade.orders = [] | ||
1344 | |||
1345 | for i in [8, 9, 11, 12]: | ||
1346 | with self.subTest(tick=i), mock.patch('sys.stdout', new_callable=StringIO) as stdout_mock: | ||
1347 | trade.update_order(order_mock, i) | ||
1348 | order_mock.cancel.assert_not_called() | ||
1349 | new_order_mock.run.assert_not_called() | ||
1350 | self.assertEqual("", stdout_mock.getvalue()) | ||
1351 | self.assertEqual(0, len(trade.orders)) | ||
1352 | |||
1353 | order_mock.reset_mock() | ||
1354 | new_order_mock.reset_mock() | ||
1355 | trade.orders = [] | ||
954 | 1356 | ||
955 | @unittest.skip("TODO") | ||
956 | def test_update_order(self): | ||
957 | pass | ||
958 | 1357 | ||
959 | @mock.patch('sys.stdout', new_callable=StringIO) | 1358 | @mock.patch('sys.stdout', new_callable=StringIO) |
960 | def test_print_with_order(self, mock_stdout): | 1359 | def test_print_with_order(self, mock_stdout): |
@@ -987,6 +1386,7 @@ class TradeTest(WebMockTestCase): | |||
987 | 1386 | ||
988 | self.assertEqual("Trade(0.50000000 BTC [10.00000000 ETH] -> 1.00000000 BTC in ETH, acquire)", str(trade)) | 1387 | self.assertEqual("Trade(0.50000000 BTC [10.00000000 ETH] -> 1.00000000 BTC in ETH, acquire)", str(trade)) |
989 | 1388 | ||
1389 | @unittest.skipUnless("acceptance" in limits, "Acceptance skipped") | ||
990 | class AcceptanceTest(WebMockTestCase): | 1390 | class AcceptanceTest(WebMockTestCase): |
991 | @unittest.expectedFailure | 1391 | @unittest.expectedFailure |
992 | def test_success_sell_only_necessary(self): | 1392 | def test_success_sell_only_necessary(self): |