2 from retry
import retry
3 from decimal
import Decimal
as D
, ROUND_DOWN
4 from ccxt
import ExchangeError
, InsufficientFunds
, ExchangeNotAvailable
, InvalidOrder
, OrderNotCached
, OrderNotFound
, RequestTimeout
, InvalidNonce
8 def eat_several(market
):
9 return lambda x
, y
: market
.ccxt
.fetch_nth_order_book(x
["symbol"], y
, 15)
12 "default": lambda x
, y
: x
[y
],
13 "average": lambda x
, y
: x
["average"],
14 "bid": lambda x
, y
: x
["bid"],
15 "ask": lambda x
, y
: x
["ask"],
19 def compute_value(cls
, ticker
, action
, compute_value
="default"):
24 if isinstance(compute_value
, str):
25 compute_value
= cls
.computations
[compute_value
]
26 return compute_value(ticker
, action
)
29 def __init__(self
, currency
, value
, linked_to
=None, ticker
=None, rate
=None):
30 self
.currency
= currency
32 self
.linked_to
= linked_to
36 def in_currency(self
, other_currency
, market
, rate
=None, action
=None, compute_value
="average"):
37 if other_currency
== self
.currency
:
45 asset_ticker
= market
.get_ticker(self
.currency
, other_currency
)
46 if asset_ticker
is not None:
47 rate
= Computation
.compute_value(asset_ticker
, action
, compute_value
=compute_value
)
55 return Amount(other_currency
, 0, linked_to
=self
, ticker
=None, rate
=0)
59 "currency": self
.currency
,
60 "value": round(self
).value
.normalize(),
63 def __round__(self
, n
=8):
64 return Amount(self
.currency
, self
.value
.quantize(D(1)/D(10**n
), rounding
=ROUND_DOWN
))
67 return Amount(self
.currency
, abs(self
.value
))
69 def __add__(self
, other
):
72 if other
.currency
!= self
.currency
and other
.value
* self
.value
!= 0:
73 raise Exception("Summing amounts must be done with same currencies")
74 return Amount(self
.currency
, self
.value
+ other
.value
)
76 def __radd__(self
, other
):
80 return self
.__add
__(other
)
82 def __sub__(self
, other
):
85 if other
.currency
!= self
.currency
and other
.value
* self
.value
!= 0:
86 raise Exception("Summing amounts must be done with same currencies")
87 return Amount(self
.currency
, self
.value
- other
.value
)
89 def __rsub__(self
, other
):
93 return -self
.__sub
__(other
)
95 def __mul__(self
, value
):
96 if not isinstance(value
, (int, float, D
)):
97 raise TypeError("Amount may only be multiplied by numbers")
98 return Amount(self
.currency
, self
.value
* value
)
100 def __rmul__(self
, value
):
101 return self
.__mul
__(value
)
103 def __floordiv__(self
, value
):
104 if not isinstance(value
, (int, float, D
)):
105 raise TypeError("Amount may only be divided by numbers")
106 return Amount(self
.currency
, self
.value
/ value
)
108 def __truediv__(self
, value
):
109 return self
.__floordiv
__(value
)
111 def __lt__(self
, other
):
113 return self
.value
< 0
114 if self
.currency
!= other
.currency
:
115 raise Exception("Comparing amounts must be done with same currencies")
116 return self
.value
< other
.value
118 def __le__(self
, other
):
119 return self
== other
or self
< other
121 def __gt__(self
, other
):
122 return not self
<= other
124 def __ge__(self
, other
):
125 return not self
< other
127 def __eq__(self
, other
):
129 return self
.value
== 0
130 if self
.currency
!= other
.currency
:
131 raise Exception("Comparing amounts must be done with same currencies")
132 return self
.value
== other
.value
134 def __ne__(self
, other
):
135 return not self
== other
138 return Amount(self
.currency
, - self
.value
)
141 if self
.linked_to
is None:
142 return "{:.8f} {}".format(self
.value
, self
.currency
)
144 return "{:.8f} {} [{}]".format(self
.value
, self
.currency
, self
.linked_to
)
147 if self
.linked_to
is None:
148 return "Amount({:.8f} {})".format(self
.value
, self
.currency
)
150 return "Amount({:.8f} {} -> {})".format(self
.value
, self
.currency
, repr(self
.linked_to
))
153 base_keys
= ["total", "exchange_total", "exchange_used",
154 "exchange_free", "margin_total", "margin_in_position",
155 "margin_available", "margin_borrowed", "margin_pending_gain"]
157 def __init__(self
, currency
, hash_
):
158 self
.currency
= currency
159 for key
in self
.base_keys
:
160 setattr(self
, key
, Amount(currency
, hash_
.get(key
, 0)))
162 self
.margin_position_type
= hash_
.get("margin_position_type")
164 if hash_
.get("margin_borrowed_base_currency") is not None:
165 base_currency
= hash_
["margin_borrowed_base_currency"]
167 "margin_liquidation_price",
168 "margin_lending_fees",
169 "margin_pending_base_gain",
170 "margin_borrowed_base_price"
172 setattr(self
, key
, Amount(base_currency
, hash_
.get(key
, 0)))
175 return dict(map(lambda x
: (x
, getattr(self
, x
).as_json()["value"]), self
.base_keys
))
178 if self
.exchange_total
> 0:
179 if self
.exchange_free
> 0 and self
.exchange_used
> 0:
180 exchange
= " Exch: [✔{} + ❌{} = {}]".format(str(self
.exchange_free
), str(self
.exchange_used
), str(self
.exchange_total
))
181 elif self
.exchange_free
> 0:
182 exchange
= " Exch: [✔{}]".format(str(self
.exchange_free
))
184 exchange
= " Exch: [❌{}]".format(str(self
.exchange_used
))
188 if self
.margin_total
> 0:
189 if self
.margin_available
!= 0 and self
.margin_in_position
!= 0:
190 margin
= " Margin: [✔{} + ❌{} = {}]".format(str(self
.margin_available
), str(self
.margin_in_position
), str(self
.margin_total
))
191 elif self
.margin_available
!= 0:
192 margin
= " Margin: [✔{}]".format(str(self
.margin_available
))
194 margin
= " Margin: [❌{}]".format(str(self
.margin_in_position
))
195 elif self
.margin_total
< 0:
196 margin
= " Margin: [{} @@ {}/{}]".format(str(self
.margin_total
),
197 str(self
.margin_borrowed_base_price
),
198 str(self
.margin_lending_fees
))
202 if self
.margin_total
!= 0 and self
.exchange_total
!= 0:
203 total
= " Total: [{}]".format(str(self
.total
))
207 return "Balance({}".format(self
.currency
) + "".join([exchange
, margin
, total
]) + ")"
210 def __init__(self
, value_from
, value_to
, currency
, market
):
211 # We have value_from of currency, and want to finish with value_to of
212 # that currency. value_* may not be in currency's terms
213 self
.currency
= currency
214 self
.value_from
= value_from
215 self
.value_to
= value_to
220 assert self
.value_from
.value
* self
.value_to
.value
>= 0
221 assert self
.value_from
.currency
== self
.value_to
.currency
222 if self
.value_from
!= 0:
223 assert self
.value_from
.linked_to
is not None and self
.value_from
.linked_to
.currency
== self
.currency
224 elif self
.value_from
.linked_to
is None:
225 self
.value_from
.linked_to
= Amount(self
.currency
, 0)
226 self
.base_currency
= self
.value_from
.currency
230 return self
.value_to
- self
.value_from
234 if self
.value_from
== self
.value_to
:
236 if self
.base_currency
== self
.currency
:
239 if abs(self
.value_from
) < abs(self
.value_to
):
244 def order_action(self
):
245 if (self
.value_from
< self
.value_to
) != self
.inverted
:
251 def trade_type(self
):
252 if self
.value_from
+ self
.value_to
< 0:
259 return not (self
.is_fullfiled
or self
.closed
)
262 for order
in self
.orders
:
267 def is_fullfiled(self
):
268 return abs(self
.filled_amount(in_base_currency
=(not self
.inverted
), refetch
=True)) >= abs(self
.delta
)
270 def filled_amount(self
, in_base_currency
=False, refetch
=False):
272 for order
in self
.orders
:
273 filled_amount
+= order
.filled_amount(in_base_currency
=in_base_currency
, refetch
=refetch
)
277 0: ["waiting", None],
278 1: ["waiting", None],
279 2: ["adjusting", lambda x
, y
: (x
[y
] + x
["average"]) / 2],
280 3: ["waiting", None],
281 4: ["waiting", None],
282 5: ["adjusting", lambda x
, y
: (x
[y
]*2 + x
["average"]) / 3],
283 6: ["waiting", None],
284 7: ["market_fallback", "default"],
287 def tick_actions_recreate(self
, tick
, default
="average"):
288 return ([default
] + \
289 [ y
[1] for x
, y
in self
.tick_actions
.items() if x
<= tick
and y
[1] is not None ])[-1]
291 def update_order(self
, order
, tick
):
292 if tick
in self
.tick_actions
:
293 update
, compute_value
= self
.tick_actions
[tick
]
296 update
= "market_adjust"
297 compute_value
= "default"
299 update
= "market_adjust_eat"
300 compute_value
= Computation
.eat_several(self
.market
)
305 if compute_value
is not None:
307 new_order
= self
.prepare_order(compute_value
=compute_value
)
311 self
.market
.report
.log_order(order
, tick
, update
=update
,
312 compute_value
=compute_value
, new_order
=new_order
)
314 if new_order
is not None:
316 self
.market
.report
.log_order(order
, tick
, new_order
=new_order
)
318 def prepare_order(self
, close_if_possible
=None, compute_value
="default"):
319 if self
.action
is None:
321 ticker
= self
.market
.get_ticker(self
.currency
, self
.base_currency
)
323 self
.market
.report
.log_error("prepare_order",
324 message
="Unknown ticker {}/{}".format(self
.currency
, self
.base_currency
))
326 self
.inverted
= ticker
["inverted"]
328 ticker
= ticker
["original"]
329 rate
= Computation
.compute_value(ticker
, self
.order_action(), compute_value
=compute_value
)
331 delta_in_base
= abs(self
.delta
)
332 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
334 if not self
.inverted
:
335 base_currency
= self
.base_currency
337 if self
.action
== "dispose":
338 filled
= self
.filled_amount(in_base_currency
=False)
339 delta
= delta_in_base
.in_currency(self
.currency
, self
.market
, rate
=1/self
.value_from
.rate
)
340 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
341 # worth of it, computed first with rate 10 FOO = 1 BTC.
342 # -> I "sell" "90" FOO at proposed rate "rate".
344 delta
= delta
- filled
345 # I already sold 60 FOO, 30 left
347 filled
= self
.filled_amount(in_base_currency
=True)
348 delta
= (delta_in_base
- filled
).in_currency(self
.currency
, self
.market
, rate
=1/rate
)
349 # I want to buy 9 BTC worth of FOO, computed with rate
351 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
353 # I already bought 3 / rate FOO, 6 / rate left
355 base_currency
= self
.currency
357 if self
.action
== "dispose":
358 filled
= self
.filled_amount(in_base_currency
=True)
361 delta
= (delta_in_base
.in_currency(self
.currency
, self
.market
, rate
=1/self
.value_from
.rate
)
362 - filled
).in_currency(self
.base_currency
, self
.market
, rate
=1/rate
)
363 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
364 # computed at rate 1 Foo = 0.01 BTC
365 # Computation says I should sell it at 125 FOO / BTC
366 # -> delta_in_base = 9 BTC
367 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
368 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
370 # I already bought 300/125 BTC, only 600/125 left
372 filled
= self
.filled_amount(in_base_currency
=False)
375 delta
= delta_in_base
376 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
377 # At rate 100 Foo / BTC
378 # Computation says I should buy it at 125 FOO / BTC
379 # -> delta_in_base = 9 BTC
380 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
382 delta
= delta
- filled
383 # I already sold 4 BTC, only 5 left
385 if close_if_possible
is None:
386 close_if_possible
= (self
.value_to
== 0)
389 self
.market
.report
.log_error("prepare_order", message
="Less to do than already filled: {}".format(delta
))
392 order
= Order(self
.order_action(),
393 delta
, rate
, base_currency
, self
.trade_type
,
394 self
.market
, self
, close_if_possible
=close_if_possible
)
395 self
.orders
.append(order
)
400 "action": self
.action
,
401 "from": self
.value_from
.as_json()["value"],
402 "to": self
.value_to
.as_json()["value"],
403 "currency": self
.currency
,
404 "base_currency": self
.base_currency
,
408 if self
.closed
and not self
.is_fullfiled
:
410 elif self
.is_fullfiled
:
415 return "Trade({} -> {} in {}, {}{})".format(
422 def print_with_order(self
, ind
=""):
423 self
.market
.report
.print_log("{}{}".format(ind
, self
))
424 for order
in self
.orders
:
425 self
.market
.report
.print_log("{}\t{}".format(ind
, order
))
426 for mouvement
in order
.mouvements
:
427 self
.market
.report
.print_log("{}\t\t{}".format(ind
, mouvement
))
429 class RetryException(Exception):
433 def __init__(self
, action
, amount
, rate
, base_currency
, trade_type
, market
,
434 trade
, close_if_possible
=False):
438 self
.base_currency
= base_currency
440 self
.trade_type
= trade_type
443 self
.status
= "pending"
445 self
.close_if_possible
= close_if_possible
448 self
.start_date
= None
452 "action": self
.action
,
453 "trade_type": self
.trade_type
,
454 "amount": self
.amount
.as_json()["value"],
455 "currency": self
.amount
.as_json()["currency"],
456 "base_currency": self
.base_currency
,
458 "status": self
.status
,
459 "close_if_possible": self
.close_if_possible
,
461 "mouvements": list(map(lambda x
: x
.as_json(), self
.mouvements
))
465 return "Order({} {} {} at {} {} [{}]{})".format(
472 " ✂" if self
.close_if_possible
else "",
477 if self
.trade_type
== "long":
484 return self
.status
== "open"
488 return self
.status
== "pending"
492 return self
.status
.startswith("closed") or self
.status
== "canceled" or self
.status
== "error"
494 @retry((InsufficientFunds
, RetryException
, InvalidNonce
))
497 symbol
= "{}/{}".format(self
.amount
.currency
, self
.base_currency
)
498 amount
= round(self
.amount
, self
.market
.ccxt
.order_precision(symbol
)).value
500 action
= "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol
, self
.action
, amount
, self
.rate
, self
.account
)
501 if self
.market
.debug
:
502 self
.market
.report
.log_debug_action(action
)
503 self
.results
.append({"debug": True, "id": -1}
)
505 self
.start_date
= datetime
.datetime
.now()
507 self
.results
.append(self
.market
.ccxt
.create_order(symbol
, 'limit', self
.action
, amount
, price
=self
.rate
, account
=self
.account
))
509 # Impossible to honor the order (dust amount)
510 self
.status
= "closed"
511 self
.mark_finished_order()
513 except InvalidNonce
as e
:
515 self
.market
.report
.log_error(action
, message
="Retrying after invalid nonce", exception
=e
)
518 self
.market
.report
.log_error(action
, message
="Giving up {} after invalid nonce".format(self
), exception
=e
)
519 self
.status
= "error"
521 except RequestTimeout
as e
:
522 if not self
.retrieve_order():
524 self
.market
.report
.log_error(action
, message
="Retrying after timeout", exception
=e
)
525 # We make a specific call in case retrieve_order
529 self
.market
.report
.log_error(action
, message
="Giving up {} after timeouts".format(self
), exception
=e
)
530 self
.status
= "error"
533 self
.market
.report
.log_error(action
, message
="Timeout, found the order")
534 except InsufficientFunds
as e
:
536 self
.market
.report
.log_error(action
, message
="Retrying with reduced amount", exception
=e
)
537 self
.amount
= self
.amount
* D("0.99")
540 self
.market
.report
.log_error(action
, message
="Giving up {}".format(self
), exception
=e
)
541 self
.status
= "error"
543 except Exception as e
:
544 self
.status
= "error"
545 self
.market
.report
.log_error(action
, exception
=e
)
547 self
.id = self
.results
[0]["id"]
550 def get_status(self
):
551 if self
.market
.debug
:
552 self
.market
.report
.log_debug_action("Getting {} status".format(self
))
554 # other states are "closed" and "canceled"
555 if not self
.finished
:
559 def mark_disappeared_order(self
):
560 if self
.status
.startswith("closed") and \
561 len(self
.mouvements
) > 0 and \
562 self
.mouvements
[-1].total_in_base
== 0:
563 self
.status
= "error_disappeared"
565 def mark_finished_order(self
):
566 if self
.status
.startswith("closed") and self
.market
.debug
:
567 self
.market
.report
.log_debug_action("Mark {} as finished".format(self
))
569 if self
.status
.startswith("closed"):
570 if self
.trade_type
== "short" and self
.action
== "buy" and self
.close_if_possible
:
571 self
.market
.ccxt
.close_margin_position(self
.amount
.currency
, self
.base_currency
)
574 if self
.market
.debug
:
575 self
.market
.report
.log_debug_action("Fetching {}".format(self
))
578 result
= self
.market
.ccxt
.fetch_order(self
.id)
579 self
.results
.append(result
)
580 self
.status
= result
["status"]
581 # Time at which the order started
582 self
.timestamp
= result
["datetime"]
583 except OrderNotCached
:
584 self
.status
= "closed_unknown"
586 self
.fetch_mouvements()
588 self
.mark_disappeared_order()
589 self
.mark_dust_amount_remaining_order()
590 self
.mark_finished_order()
592 def mark_dust_amount_remaining_order(self
):
593 if self
.status
== "open" and self
.market
.ccxt
.is_dust_trade(self
.remaining_amount().value
, self
.rate
):
594 self
.status
= "closed_dust_remaining"
596 def remaining_amount(self
, refetch
=False):
597 return self
.amount
- self
.filled_amount(refetch
=refetch
)
599 def filled_amount(self
, in_base_currency
=False, refetch
=False):
600 if refetch
and self
.status
== "open":
603 for mouvement
in self
.mouvements
:
605 filled_amount
+= mouvement
.total_in_base
607 filled_amount
+= mouvement
.total
610 def fetch_mouvements(self
):
612 mouvements
= self
.market
.ccxt
.privatePostReturnOrderTrades({"orderNumber": self.id}
)
613 except ExchangeError
:
617 for mouvement_hash
in mouvements
:
618 self
.mouvements
.append(Mouvement(self
.amount
.currency
,
619 self
.base_currency
, mouvement_hash
))
620 self
.mouvements
.sort(key
= lambda x
: x
.date
)
623 if self
.market
.debug
:
624 self
.market
.report
.log_debug_action("Mark {} as cancelled".format(self
))
625 self
.status
= "canceled"
627 if (self
.status
== "closed_dust_remaining" or self
.open) and self
.id is not None:
629 self
.market
.ccxt
.cancel_order(self
.id)
630 except OrderNotFound
as e
: # Closed inbetween
631 self
.market
.report
.log_error("cancel_order", message
="Already cancelled order", exception
=e
)
634 def retrieve_order(self
):
635 symbol
= "{}/{}".format(self
.amount
.currency
, self
.base_currency
)
636 amount
= round(self
.amount
, self
.market
.ccxt
.order_precision(symbol
)).value
637 start_timestamp
= self
.start_date
.timestamp() - 5
639 similar_open_orders
= self
.market
.ccxt
.fetch_orders(symbol
=symbol
, since
=start_timestamp
)
640 for order
in similar_open_orders
:
641 if (order
["info"]["margin"] == 1 and self
.account
== "exchange") or\
642 (order
["info"]["margin"] != 1 and self
.account
== "margin"):
643 i_m_tested
= True # coverage bug ?!
645 if order
["info"]["side"] != self
.action
:
648 abs(D(order
["info"]["startingAmount"]) - amount
),
649 self
.market
.ccxt
.order_precision(symbol
))
651 abs(D(order
["info"]["rate"]) - self
.rate
),
652 self
.market
.ccxt
.order_precision(symbol
))
653 if amount_diff
!= 0 or rate_diff
!= 0:
655 self
.results
.append({"id": order["id"]}
)
658 similar_trades
= self
.market
.ccxt
.fetch_my_trades(symbol
=symbol
, since
=start_timestamp
)
659 for order_id
in sorted(list(map(lambda x
: x
["order"], similar_trades
))):
660 trades
= list(filter(lambda x
: x
["order"] == order_id
, similar_trades
))
661 if any(x
["timestamp"] < start_timestamp
for x
in trades
):
663 if any(x
["side"] != self
.action
for x
in trades
):
665 if any(x
["info"]["category"] == "exchange" and self
.account
== "margin" for x
in trades
) or\
666 any(x
["info"]["category"] == "marginTrade" and self
.account
== "exchange" for x
in trades
):
668 trade_sum
= sum(D(x
["info"]["amount"]) for x
in trades
)
669 amount_diff
= round(abs(trade_sum
- amount
),
670 self
.market
.ccxt
.order_precision(symbol
))
673 if (self
.action
== "sell" and any(D(x
["info"]["rate"]) < self
.rate
for x
in trades
)) or\
674 (self
.action
== "buy" and any(D(x
["info"]["rate"]) > self
.rate
for x
in trades
)):
676 self
.results
.append({"id": order_id}
)
682 def __init__(self
, currency
, base_currency
, hash_
):
683 self
.currency
= currency
684 self
.base_currency
= base_currency
685 self
.id = hash_
.get("tradeID")
686 self
.action
= hash_
.get("type")
687 self
.fee_rate
= D(hash_
.get("fee", -1))
689 self
.date
= datetime
.datetime
.strptime(hash_
.get("date", ""), '%Y-%m-%d %H:%M:%S')
692 self
.rate
= D(hash_
.get("rate", 0))
693 self
.total
= Amount(currency
, hash_
.get("amount", 0))
694 # rate * total = total_in_base
695 self
.total_in_base
= Amount(base_currency
, hash_
.get("total", 0))
699 "fee_rate": self
.fee_rate
,
701 "action": self
.action
,
702 "total": self
.total
.value
,
703 "currency": self
.currency
,
704 "total_in_base": self
.total_in_base
.value
,
705 "base_currency": self
.base_currency
709 if self
.fee_rate
> 0:
710 fee_rate
= " fee: {}%".format(self
.fee_rate
* 100)
713 if self
.date
is None:
717 return "Mouvement({} ; {} {} ({}){})".format(
718 date
, self
.action
, self
.total
, self
.total_in_base
,