1 from datetime
import datetime
2 from decimal
import Decimal
as D
, ROUND_DOWN
3 from ccxt
import ExchangeError
, InsufficientFunds
, ExchangeNotAvailable
, InvalidOrder
, OrderNotCached
, OrderNotFound
4 from retry
import retry
6 # FIXME: correctly handle web call timeouts
10 "default": lambda x
, y
: x
[y
],
11 "average": lambda x
, y
: x
["average"],
12 "bid": lambda x
, y
: x
["bid"],
13 "ask": lambda x
, y
: x
["ask"],
17 def compute_value(cls
, ticker
, action
, compute_value
="default"):
22 if isinstance(compute_value
, str):
23 compute_value
= cls
.computations
[compute_value
]
24 return compute_value(ticker
, action
)
27 def __init__(self
, currency
, value
, linked_to
=None, ticker
=None, rate
=None):
28 self
.currency
= currency
30 self
.linked_to
= linked_to
34 def in_currency(self
, other_currency
, market
, rate
=None, action
=None, compute_value
="average"):
35 if other_currency
== self
.currency
:
43 asset_ticker
= market
.get_ticker(self
.currency
, other_currency
)
44 if asset_ticker
is not None:
45 rate
= Computation
.compute_value(asset_ticker
, action
, compute_value
=compute_value
)
53 raise Exception("This asset is not available in the chosen market")
57 "currency": self
.currency
,
58 "value": round(self
).value
.normalize(),
61 def __round__(self
, n
=8):
62 return Amount(self
.currency
, self
.value
.quantize(D(1)/D(10**n
), rounding
=ROUND_DOWN
))
65 return Amount(self
.currency
, abs(self
.value
))
67 def __add__(self
, other
):
70 if other
.currency
!= self
.currency
and other
.value
* self
.value
!= 0:
71 raise Exception("Summing amounts must be done with same currencies")
72 return Amount(self
.currency
, self
.value
+ other
.value
)
74 def __radd__(self
, other
):
78 return self
.__add
__(other
)
80 def __sub__(self
, other
):
83 if other
.currency
!= self
.currency
and other
.value
* self
.value
!= 0:
84 raise Exception("Summing amounts must be done with same currencies")
85 return Amount(self
.currency
, self
.value
- other
.value
)
87 def __rsub__(self
, other
):
91 return -self
.__sub
__(other
)
93 def __mul__(self
, value
):
94 if not isinstance(value
, (int, float, D
)):
95 raise TypeError("Amount may only be multiplied by numbers")
96 return Amount(self
.currency
, self
.value
* value
)
98 def __rmul__(self
, value
):
99 return self
.__mul
__(value
)
101 def __floordiv__(self
, value
):
102 if not isinstance(value
, (int, float, D
)):
103 raise TypeError("Amount may only be divided by numbers")
104 return Amount(self
.currency
, self
.value
/ value
)
106 def __truediv__(self
, value
):
107 return self
.__floordiv
__(value
)
109 def __lt__(self
, other
):
111 return self
.value
< 0
112 if self
.currency
!= other
.currency
:
113 raise Exception("Comparing amounts must be done with same currencies")
114 return self
.value
< other
.value
116 def __le__(self
, other
):
117 return self
== other
or self
< other
119 def __gt__(self
, other
):
120 return not self
<= other
122 def __ge__(self
, other
):
123 return not self
< other
125 def __eq__(self
, other
):
127 return self
.value
== 0
128 if self
.currency
!= other
.currency
:
129 raise Exception("Comparing amounts must be done with same currencies")
130 return self
.value
== other
.value
132 def __ne__(self
, other
):
133 return not self
== other
136 return Amount(self
.currency
, - self
.value
)
139 if self
.linked_to
is None:
140 return "{:.8f} {}".format(self
.value
, self
.currency
)
142 return "{:.8f} {} [{}]".format(self
.value
, self
.currency
, self
.linked_to
)
145 if self
.linked_to
is None:
146 return "Amount({:.8f} {})".format(self
.value
, self
.currency
)
148 return "Amount({:.8f} {} -> {})".format(self
.value
, self
.currency
, repr(self
.linked_to
))
151 base_keys
= ["total", "exchange_total", "exchange_used",
152 "exchange_free", "margin_total", "margin_in_position",
153 "margin_available", "margin_borrowed", "margin_pending_gain"]
155 def __init__(self
, currency
, hash_
):
156 self
.currency
= currency
157 for key
in self
.base_keys
:
158 setattr(self
, key
, Amount(currency
, hash_
.get(key
, 0)))
160 self
.margin_position_type
= hash_
.get("margin_position_type")
162 if hash_
.get("margin_borrowed_base_currency") is not None:
163 base_currency
= hash_
["margin_borrowed_base_currency"]
165 "margin_liquidation_price",
166 "margin_lending_fees",
167 "margin_pending_base_gain",
168 "margin_borrowed_base_price"
170 setattr(self
, key
, Amount(base_currency
, hash_
.get(key
, 0)))
173 return dict(map(lambda x
: (x
, getattr(self
, x
).as_json()["value"]), self
.base_keys
))
176 if self
.exchange_total
> 0:
177 if self
.exchange_free
> 0 and self
.exchange_used
> 0:
178 exchange
= " Exch: [✔{} + ❌{} = {}]".format(str(self
.exchange_free
), str(self
.exchange_used
), str(self
.exchange_total
))
179 elif self
.exchange_free
> 0:
180 exchange
= " Exch: [✔{}]".format(str(self
.exchange_free
))
182 exchange
= " Exch: [❌{}]".format(str(self
.exchange_used
))
186 if self
.margin_total
> 0:
187 if self
.margin_available
!= 0 and self
.margin_in_position
!= 0:
188 margin
= " Margin: [✔{} + ❌{} = {}]".format(str(self
.margin_available
), str(self
.margin_in_position
), str(self
.margin_total
))
189 elif self
.margin_available
!= 0:
190 margin
= " Margin: [✔{}]".format(str(self
.margin_available
))
192 margin
= " Margin: [❌{}]".format(str(self
.margin_in_position
))
193 elif self
.margin_total
< 0:
194 margin
= " Margin: [{} @@ {}/{}]".format(str(self
.margin_total
),
195 str(self
.margin_borrowed_base_price
),
196 str(self
.margin_lending_fees
))
200 if self
.margin_total
!= 0 and self
.exchange_total
!= 0:
201 total
= " Total: [{}]".format(str(self
.total
))
205 return "Balance({}".format(self
.currency
) + "".join([exchange
, margin
, total
]) + ")"
208 def __init__(self
, value_from
, value_to
, currency
, market
):
209 # We have value_from of currency, and want to finish with value_to of
210 # that currency. value_* may not be in currency's terms
211 self
.currency
= currency
212 self
.value_from
= value_from
213 self
.value_to
= value_to
217 assert self
.value_from
.value
* self
.value_to
.value
>= 0
218 assert self
.value_from
.currency
== self
.value_to
.currency
219 if self
.value_from
!= 0:
220 assert self
.value_from
.linked_to
is not None and self
.value_from
.linked_to
.currency
== self
.currency
221 elif self
.value_from
.linked_to
is None:
222 self
.value_from
.linked_to
= Amount(self
.currency
, 0)
223 self
.base_currency
= self
.value_from
.currency
227 return self
.value_to
- self
.value_from
231 if self
.value_from
== self
.value_to
:
233 if self
.base_currency
== self
.currency
:
236 if abs(self
.value_from
) < abs(self
.value_to
):
241 def order_action(self
, inverted
):
242 if (self
.value_from
< self
.value_to
) != inverted
:
248 def trade_type(self
):
249 if self
.value_from
+ self
.value_to
< 0:
256 return not (self
.is_fullfiled
or self
.closed
)
259 for order
in self
.orders
:
264 def is_fullfiled(self
):
265 return abs(self
.filled_amount(in_base_currency
=True)) >= abs(self
.delta
)
267 def filled_amount(self
, in_base_currency
=False):
269 for order
in self
.orders
:
270 filled_amount
+= order
.filled_amount(in_base_currency
=in_base_currency
)
273 def update_order(self
, order
, tick
):
275 0: ["waiting", None],
276 1: ["waiting", None],
277 2: ["adjusting", lambda x
, y
: (x
[y
] + x
["average"]) / 2],
278 3: ["waiting", None],
279 4: ["waiting", None],
280 5: ["adjusting", lambda x
, y
: (x
[y
]*2 + x
["average"]) / 3],
281 6: ["waiting", None],
282 7: ["market_fallback", "default"],
286 update
, compute_value
= actions
[tick
]
288 update
= "market_adjust"
289 compute_value
= "default"
294 if compute_value
is not None:
296 new_order
= self
.prepare_order(compute_value
=compute_value
)
300 self
.market
.report
.log_order(order
, tick
, update
=update
,
301 compute_value
=compute_value
, new_order
=new_order
)
303 if new_order
is not None:
305 self
.market
.report
.log_order(order
, tick
, new_order
=new_order
)
307 def prepare_order(self
, close_if_possible
=None, compute_value
="default"):
308 if self
.action
is None:
310 ticker
= self
.market
.get_ticker(self
.currency
, self
.base_currency
)
311 inverted
= ticker
["inverted"]
313 ticker
= ticker
["original"]
314 rate
= Computation
.compute_value(ticker
, self
.order_action(inverted
), compute_value
=compute_value
)
316 # FIXME: Dust amount should be removed from there if they werent
317 # honored in other sales
318 delta_in_base
= abs(self
.delta
)
319 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
322 base_currency
= self
.base_currency
324 if self
.action
== "dispose":
325 filled
= self
.filled_amount(in_base_currency
=False)
326 delta
= delta_in_base
.in_currency(self
.currency
, self
.market
, rate
=1/self
.value_from
.rate
)
327 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
328 # worth of it, computed first with rate 10 FOO = 1 BTC.
329 # -> I "sell" "90" FOO at proposed rate "rate".
331 delta
= delta
- filled
332 # I already sold 60 FOO, 30 left
334 filled
= self
.filled_amount(in_base_currency
=True)
335 delta
= (delta_in_base
- filled
).in_currency(self
.currency
, self
.market
, rate
=1/rate
)
336 # I want to buy 9 BTC worth of FOO, computed with rate
338 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
340 # I already bought 3 / rate FOO, 6 / rate left
342 base_currency
= self
.currency
344 if self
.action
== "dispose":
345 filled
= self
.filled_amount(in_base_currency
=True)
348 delta
= (delta_in_base
.in_currency(self
.currency
, self
.market
, rate
=1/self
.value_from
.rate
)
349 - filled
).in_currency(self
.base_currency
, self
.market
, rate
=1/rate
)
350 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
351 # computed at rate 1 Foo = 0.01 BTC
352 # Computation says I should sell it at 125 FOO / BTC
353 # -> delta_in_base = 9 BTC
354 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
355 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
357 # I already bought 300/125 BTC, only 600/125 left
359 filled
= self
.filled_amount(in_base_currency
=False)
362 delta
= delta_in_base
363 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
364 # At rate 100 Foo / BTC
365 # Computation says I should buy it at 125 FOO / BTC
366 # -> delta_in_base = 9 BTC
367 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
369 delta
= delta
- filled
370 # I already sold 4 BTC, only 5 left
372 if close_if_possible
is None:
373 close_if_possible
= (self
.value_to
== 0)
376 self
.market
.report
.log_error("prepare_order", message
="Less to do than already filled: {}".format(delta
))
379 order
= Order(self
.order_action(inverted
),
380 delta
, rate
, base_currency
, self
.trade_type
,
381 self
.market
, self
, close_if_possible
=close_if_possible
)
382 self
.orders
.append(order
)
387 "action": self
.action
,
388 "from": self
.value_from
.as_json()["value"],
389 "to": self
.value_to
.as_json()["value"],
390 "currency": self
.currency
,
391 "base_currency": self
.base_currency
,
395 if self
.closed
and not self
.is_fullfiled
:
397 elif self
.is_fullfiled
:
402 return "Trade({} -> {} in {}, {}{})".format(
409 def print_with_order(self
, ind
=""):
410 self
.market
.report
.print_log("{}{}".format(ind
, self
))
411 for order
in self
.orders
:
412 self
.market
.report
.print_log("{}\t{}".format(ind
, order
))
413 for mouvement
in order
.mouvements
:
414 self
.market
.report
.print_log("{}\t\t{}".format(ind
, mouvement
))
417 def __init__(self
, action
, amount
, rate
, base_currency
, trade_type
, market
,
418 trade
, close_if_possible
=False):
422 self
.base_currency
= base_currency
424 self
.trade_type
= trade_type
427 self
.status
= "pending"
429 self
.close_if_possible
= close_if_possible
435 "action": self
.action
,
436 "trade_type": self
.trade_type
,
437 "amount": self
.amount
.as_json()["value"],
438 "currency": self
.amount
.as_json()["currency"],
439 "base_currency": self
.base_currency
,
441 "status": self
.status
,
442 "close_if_possible": self
.close_if_possible
,
444 "mouvements": list(map(lambda x
: x
.as_json(), self
.mouvements
))
448 return "Order({} {} {} at {} {} [{}]{})".format(
455 " ✂" if self
.close_if_possible
else "",
460 if self
.trade_type
== "long":
467 return self
.status
== "open"
471 return self
.status
== "pending"
475 return self
.status
== "closed" or self
.status
== "canceled" or self
.status
== "error"
477 @retry(InsufficientFunds
)
480 symbol
= "{}/{}".format(self
.amount
.currency
, self
.base_currency
)
481 amount
= round(self
.amount
, self
.market
.ccxt
.order_precision(symbol
)).value
483 if self
.market
.debug
:
484 self
.market
.report
.log_debug_action("market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(
485 symbol
, self
.action
, amount
, self
.rate
, self
.account
))
486 self
.results
.append({"debug": True, "id": -1}
)
488 action
= "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol
, self
.action
, amount
, self
.rate
, self
.account
)
490 self
.results
.append(self
.market
.ccxt
.create_order(symbol
, 'limit', self
.action
, amount
, price
=self
.rate
, account
=self
.account
))
492 # Impossible to honor the order (dust amount)
493 self
.status
= "closed"
494 self
.mark_finished_order()
496 except InsufficientFunds
as e
:
498 self
.market
.report
.log_error(action
, message
="Retrying with reduced amount", exception
=e
)
499 self
.amount
= self
.amount
* D("0.99")
502 self
.market
.report
.log_error(action
, message
="Giving up {}".format(self
), exception
=e
)
503 self
.status
= "error"
505 except Exception as e
:
506 self
.status
= "error"
507 self
.market
.report
.log_error(action
, exception
=e
)
509 self
.id = self
.results
[0]["id"]
512 def get_status(self
):
513 if self
.market
.debug
:
514 self
.market
.report
.log_debug_action("Getting {} status".format(self
))
516 # other states are "closed" and "canceled"
517 if not self
.finished
:
520 self
.mark_finished_order()
523 def mark_finished_order(self
):
524 if self
.market
.debug
:
525 self
.market
.report
.log_debug_action("Mark {} as finished".format(self
))
527 if self
.status
== "closed":
528 if self
.trade_type
== "short" and self
.action
== "buy" and self
.close_if_possible
:
529 self
.market
.ccxt
.close_margin_position(self
.amount
.currency
, self
.base_currency
)
532 if self
.market
.debug
:
533 self
.market
.report
.log_debug_action("Fetching {}".format(self
))
536 result
= self
.market
.ccxt
.fetch_order(self
.id)
537 self
.results
.append(result
)
538 self
.status
= result
["status"]
539 # Time at which the order started
540 self
.timestamp
= result
["datetime"]
541 except OrderNotCached
:
542 self
.status
= "closed_unknown"
544 self
.fetch_mouvements()
546 # FIXME: consider open order with dust remaining as closed
548 def dust_amount_remaining(self
):
549 return self
.remaining_amount() < Amount(self
.amount
.currency
, D("0.001"))
551 def remaining_amount(self
):
552 return self
.amount
- self
.filled_amount()
554 def filled_amount(self
, in_base_currency
=False):
555 if self
.status
== "open":
558 for mouvement
in self
.mouvements
:
560 filled_amount
+= mouvement
.total_in_base
562 filled_amount
+= mouvement
.total
565 def fetch_mouvements(self
):
567 mouvements
= self
.market
.ccxt
.privatePostReturnOrderTrades({"orderNumber": self.id}
)
568 except ExchangeError
:
572 for mouvement_hash
in mouvements
:
573 self
.mouvements
.append(Mouvement(self
.amount
.currency
,
574 self
.base_currency
, mouvement_hash
))
577 if self
.market
.debug
:
578 self
.market
.report
.log_debug_action("Mark {} as cancelled".format(self
))
579 self
.status
= "canceled"
581 if self
.open and self
.id is not None:
583 self
.market
.ccxt
.cancel_order(self
.id)
584 except OrderNotFound
as e
: # Closed inbetween
585 self
.market
.report
.log_error("cancel_order", message
="Already cancelled order", exception
=e
)
589 def __init__(self
, currency
, base_currency
, hash_
):
590 self
.currency
= currency
591 self
.base_currency
= base_currency
592 self
.id = hash_
.get("tradeID")
593 self
.action
= hash_
.get("type")
594 self
.fee_rate
= D(hash_
.get("fee", -1))
596 self
.date
= datetime
.strptime(hash_
.get("date", ""), '%Y-%m-%d %H:%M:%S')
599 self
.rate
= D(hash_
.get("rate", 0))
600 self
.total
= Amount(currency
, hash_
.get("amount", 0))
601 # rate * total = total_in_base
602 self
.total_in_base
= Amount(base_currency
, hash_
.get("total", 0))
606 "fee_rate": self
.fee_rate
,
608 "action": self
.action
,
609 "total": self
.total
.value
,
610 "currency": self
.currency
,
611 "total_in_base": self
.total_in_base
.value
,
612 "base_currency": self
.base_currency
616 if self
.fee_rate
> 0:
617 fee_rate
= " fee: {}%".format(self
.fee_rate
* 100)
620 if self
.date
is None:
624 return "Mouvement({} ; {} {} ({}){})".format(
625 date
, self
.action
, self
.total
, self
.total_in_base
,