]> git.immae.eu Git - perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git/blame - portfolio.py
Fix ccxt switching currency codes
[perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git] / portfolio.py
CommitLineData
e7d7c0e5 1import datetime
f70bb858 2from retry import retry
7f3e7d27 3from decimal import Decimal as D, ROUND_DOWN
b47d7b54 4from ccxt import ExchangeError, InsufficientFunds, ExchangeNotAvailable, InvalidOrder, OrderNotCached, OrderNotFound, RequestTimeout, InvalidNonce
80cdd672 5
6ca5a1ec
IB
6class Computation:
7 computations = {
8 "default": lambda x, y: x[y],
9 "average": lambda x, y: x["average"],
10 "bid": lambda x, y: x["bid"],
11 "ask": lambda x, y: x["ask"],
12 }
13
14 @classmethod
15 def compute_value(cls, ticker, action, compute_value="default"):
16 if action == "buy":
17 action = "ask"
18 if action == "sell":
19 action = "bid"
20 if isinstance(compute_value, str):
21 compute_value = cls.computations[compute_value]
22 return compute_value(ticker, action)
23
dd359bc0 24class Amount:
c2644ba8 25 def __init__(self, currency, value, linked_to=None, ticker=None, rate=None):
dd359bc0 26 self.currency = currency
5ab23e1c 27 self.value = D(value)
dd359bc0
IB
28 self.linked_to = linked_to
29 self.ticker = ticker
c2644ba8 30 self.rate = rate
dd359bc0 31
c2644ba8 32 def in_currency(self, other_currency, market, rate=None, action=None, compute_value="average"):
dd359bc0
IB
33 if other_currency == self.currency:
34 return self
c2644ba8
IB
35 if rate is not None:
36 return Amount(
37 other_currency,
38 self.value * rate,
39 linked_to=self,
40 rate=rate)
f86ee140 41 asset_ticker = market.get_ticker(self.currency, other_currency)
dd359bc0 42 if asset_ticker is not None:
6ca5a1ec 43 rate = Computation.compute_value(asset_ticker, action, compute_value=compute_value)
dd359bc0
IB
44 return Amount(
45 other_currency,
c2644ba8 46 self.value * rate,
dd359bc0 47 linked_to=self,
c2644ba8
IB
48 ticker=asset_ticker,
49 rate=rate)
dd359bc0
IB
50 else:
51 raise Exception("This asset is not available in the chosen market")
52
3d0247f9
IB
53 def as_json(self):
54 return {
55 "currency": self.currency,
56 "value": round(self).value.normalize(),
57 }
58
350ed24d
IB
59 def __round__(self, n=8):
60 return Amount(self.currency, self.value.quantize(D(1)/D(10**n), rounding=ROUND_DOWN))
61
dd359bc0 62 def __abs__(self):
5ab23e1c 63 return Amount(self.currency, abs(self.value))
dd359bc0
IB
64
65 def __add__(self, other):
f320eb8a
IB
66 if other == 0:
67 return self
5ab23e1c 68 if other.currency != self.currency and other.value * self.value != 0:
dd359bc0 69 raise Exception("Summing amounts must be done with same currencies")
5ab23e1c 70 return Amount(self.currency, self.value + other.value)
dd359bc0
IB
71
72 def __radd__(self, other):
73 if other == 0:
74 return self
75 else:
76 return self.__add__(other)
77
78 def __sub__(self, other):
c51687d2
IB
79 if other == 0:
80 return self
5ab23e1c 81 if other.currency != self.currency and other.value * self.value != 0:
dd359bc0 82 raise Exception("Summing amounts must be done with same currencies")
5ab23e1c 83 return Amount(self.currency, self.value - other.value)
dd359bc0 84
f320eb8a
IB
85 def __rsub__(self, other):
86 if other == 0:
87 return -self
88 else:
89 return -self.__sub__(other)
90
dd359bc0 91 def __mul__(self, value):
77f8a378 92 if not isinstance(value, (int, float, D)):
dd359bc0 93 raise TypeError("Amount may only be multiplied by numbers")
5ab23e1c 94 return Amount(self.currency, self.value * value)
dd359bc0
IB
95
96 def __rmul__(self, value):
97 return self.__mul__(value)
98
99 def __floordiv__(self, value):
77f8a378 100 if not isinstance(value, (int, float, D)):
1aa7d4fa 101 raise TypeError("Amount may only be divided by numbers")
5ab23e1c 102 return Amount(self.currency, self.value / value)
dd359bc0
IB
103
104 def __truediv__(self, value):
105 return self.__floordiv__(value)
106
107 def __lt__(self, other):
006a2084
IB
108 if other == 0:
109 return self.value < 0
dd359bc0
IB
110 if self.currency != other.currency:
111 raise Exception("Comparing amounts must be done with same currencies")
5ab23e1c 112 return self.value < other.value
dd359bc0 113
80cdd672
IB
114 def __le__(self, other):
115 return self == other or self < other
116
006a2084
IB
117 def __gt__(self, other):
118 return not self <= other
119
120 def __ge__(self, other):
121 return not self < other
122
dd359bc0
IB
123 def __eq__(self, other):
124 if other == 0:
5ab23e1c 125 return self.value == 0
dd359bc0
IB
126 if self.currency != other.currency:
127 raise Exception("Comparing amounts must be done with same currencies")
5ab23e1c 128 return self.value == other.value
dd359bc0 129
006a2084
IB
130 def __ne__(self, other):
131 return not self == other
132
133 def __neg__(self):
134 return Amount(self.currency, - self.value)
135
dd359bc0
IB
136 def __str__(self):
137 if self.linked_to is None:
138 return "{:.8f} {}".format(self.value, self.currency)
139 else:
140 return "{:.8f} {} [{}]".format(self.value, self.currency, self.linked_to)
141
142 def __repr__(self):
143 if self.linked_to is None:
144 return "Amount({:.8f} {})".format(self.value, self.currency)
145 else:
146 return "Amount({:.8f} {} -> {})".format(self.value, self.currency, repr(self.linked_to))
147
148class Balance:
3d0247f9 149 base_keys = ["total", "exchange_total", "exchange_used",
aca4d437
IB
150 "exchange_free", "margin_total", "margin_in_position",
151 "margin_available", "margin_borrowed", "margin_pending_gain"]
dd359bc0 152
006a2084 153 def __init__(self, currency, hash_):
dd359bc0 154 self.currency = currency
3d0247f9 155 for key in self.base_keys:
006a2084
IB
156 setattr(self, key, Amount(currency, hash_.get(key, 0)))
157
80cdd672 158 self.margin_position_type = hash_.get("margin_position_type")
006a2084 159
80cdd672 160 if hash_.get("margin_borrowed_base_currency") is not None:
006a2084
IB
161 base_currency = hash_["margin_borrowed_base_currency"]
162 for key in [
163 "margin_liquidation_price",
006a2084 164 "margin_lending_fees",
aca4d437 165 "margin_pending_base_gain",
006a2084
IB
166 "margin_borrowed_base_price"
167 ]:
c51687d2 168 setattr(self, key, Amount(base_currency, hash_.get(key, 0)))
f2da6589 169
3d0247f9
IB
170 def as_json(self):
171 return dict(map(lambda x: (x, getattr(self, x).as_json()["value"]), self.base_keys))
172
dd359bc0 173 def __repr__(self):
006a2084
IB
174 if self.exchange_total > 0:
175 if self.exchange_free > 0 and self.exchange_used > 0:
176 exchange = " Exch: [✔{} + ❌{} = {}]".format(str(self.exchange_free), str(self.exchange_used), str(self.exchange_total))
177 elif self.exchange_free > 0:
178 exchange = " Exch: [✔{}]".format(str(self.exchange_free))
179 else:
180 exchange = " Exch: [❌{}]".format(str(self.exchange_used))
181 else:
182 exchange = ""
183
184 if self.margin_total > 0:
aca4d437
IB
185 if self.margin_available != 0 and self.margin_in_position != 0:
186 margin = " Margin: [✔{} + ❌{} = {}]".format(str(self.margin_available), str(self.margin_in_position), str(self.margin_total))
187 elif self.margin_available != 0:
188 margin = " Margin: [✔{}]".format(str(self.margin_available))
006a2084 189 else:
aca4d437 190 margin = " Margin: [❌{}]".format(str(self.margin_in_position))
006a2084
IB
191 elif self.margin_total < 0:
192 margin = " Margin: [{} @@ {}/{}]".format(str(self.margin_total),
193 str(self.margin_borrowed_base_price),
194 str(self.margin_lending_fees))
195 else:
196 margin = ""
197
198 if self.margin_total != 0 and self.exchange_total != 0:
199 total = " Total: [{}]".format(str(self.total))
200 else:
201 total = ""
202
203 return "Balance({}".format(self.currency) + "".join([exchange, margin, total]) + ")"
dd359bc0
IB
204
205class Trade:
f86ee140 206 def __init__(self, value_from, value_to, currency, market):
dd359bc0
IB
207 # We have value_from of currency, and want to finish with value_to of
208 # that currency. value_* may not be in currency's terms
209 self.currency = currency
210 self.value_from = value_from
211 self.value_to = value_to
212 self.orders = []
089d5d9d 213 self.market = market
17598517 214 self.closed = False
186f7d81 215 self.inverted = None
aca4d437 216 assert self.value_from.value * self.value_to.value >= 0
dd359bc0 217 assert self.value_from.currency == self.value_to.currency
006a2084
IB
218 if self.value_from != 0:
219 assert self.value_from.linked_to is not None and self.value_from.linked_to.currency == self.currency
220 elif self.value_from.linked_to is None:
221 self.value_from.linked_to = Amount(self.currency, 0)
dd359bc0
IB
222 self.base_currency = self.value_from.currency
223
aca4d437
IB
224 @property
225 def delta(self):
226 return self.value_to - self.value_from
227
dd359bc0
IB
228 @property
229 def action(self):
230 if self.value_from == self.value_to:
231 return None
232 if self.base_currency == self.currency:
233 return None
234
5a72ded7 235 if abs(self.value_from) < abs(self.value_to):
006a2084 236 return "acquire"
dd359bc0 237 else:
006a2084 238 return "dispose"
dd359bc0 239
186f7d81
IB
240 def order_action(self):
241 if (self.value_from < self.value_to) != self.inverted:
350ed24d 242 return "buy"
dd359bc0 243 else:
350ed24d 244 return "sell"
dd359bc0 245
006a2084
IB
246 @property
247 def trade_type(self):
248 if self.value_from + self.value_to < 0:
249 return "short"
250 else:
251 return "long"
252
17598517
IB
253 @property
254 def pending(self):
255 return not (self.is_fullfiled or self.closed)
256
257 def close(self):
f9226903
IB
258 for order in self.orders:
259 order.cancel()
17598517
IB
260 self.closed = True
261
aca4d437
IB
262 @property
263 def is_fullfiled(self):
186f7d81 264 return abs(self.filled_amount(in_base_currency=(not self.inverted))) >= abs(self.delta)
aca4d437 265
1aa7d4fa 266 def filled_amount(self, in_base_currency=False):
80cdd672
IB
267 filled_amount = 0
268 for order in self.orders:
1aa7d4fa 269 filled_amount += order.filled_amount(in_base_currency=in_base_currency)
80cdd672
IB
270 return filled_amount
271
7ba831c5
IB
272 tick_actions = {
273 0: ["waiting", None],
274 1: ["waiting", None],
275 2: ["adjusting", lambda x, y: (x[y] + x["average"]) / 2],
276 3: ["waiting", None],
277 4: ["waiting", None],
278 5: ["adjusting", lambda x, y: (x[y]*2 + x["average"]) / 3],
279 6: ["waiting", None],
280 7: ["market_fallback", "default"],
281 }
aca4d437 282
7ba831c5
IB
283 def tick_actions_recreate(self, tick, default="average"):
284 return ([default] + \
285 [ y[1] for x, y in self.tick_actions.items() if x <= tick and y[1] is not None ])[-1]
286
287 def update_order(self, order, tick):
288 if tick in self.tick_actions:
289 update, compute_value = self.tick_actions[tick]
aca4d437
IB
290 elif tick % 3 == 1:
291 update = "market_adjust"
292 compute_value = "default"
293 else:
3d0247f9
IB
294 update = "waiting"
295 compute_value = None
aca4d437
IB
296
297 if compute_value is not None:
298 order.cancel()
299 new_order = self.prepare_order(compute_value=compute_value)
300 else:
301 new_order = None
3d0247f9 302
f86ee140 303 self.market.report.log_order(order, tick, update=update,
3d0247f9 304 compute_value=compute_value, new_order=new_order)
80cdd672
IB
305
306 if new_order is not None:
80cdd672 307 new_order.run()
f86ee140 308 self.market.report.log_order(order, tick, new_order=new_order)
80cdd672 309
2033e7fe 310 def prepare_order(self, close_if_possible=None, compute_value="default"):
dd359bc0 311 if self.action is None:
5a72ded7 312 return None
f86ee140 313 ticker = self.market.get_ticker(self.currency, self.base_currency)
18de421e
IB
314 if ticker is None:
315 self.market.report.log_error("prepare_order",
316 message="Unknown ticker {}/{}".format(self.currency, self.base_currency))
317 return None
186f7d81
IB
318 self.inverted = ticker["inverted"]
319 if self.inverted:
f2097d71 320 ticker = ticker["original"]
186f7d81 321 rate = Computation.compute_value(ticker, self.order_action(), compute_value=compute_value)
f2097d71 322
97922ff1
IB
323 # FIXME: Dust amount should be removed from there if they werent
324 # honored in other sales
aca4d437 325 delta_in_base = abs(self.delta)
c11e4274 326 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
dd359bc0 327
186f7d81 328 if not self.inverted:
1aa7d4fa 329 base_currency = self.base_currency
350ed24d 330 # BTC
006a2084 331 if self.action == "dispose":
1aa7d4fa
IB
332 filled = self.filled_amount(in_base_currency=False)
333 delta = delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
334 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
335 # worth of it, computed first with rate 10 FOO = 1 BTC.
336 # -> I "sell" "90" FOO at proposed rate "rate".
337
338 delta = delta - filled
339 # I already sold 60 FOO, 30 left
f2097d71 340 else:
1aa7d4fa
IB
341 filled = self.filled_amount(in_base_currency=True)
342 delta = (delta_in_base - filled).in_currency(self.currency, self.market, rate=1/rate)
343 # I want to buy 9 BTC worth of FOO, computed with rate
344 # 10 FOO = 1 BTC
345 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
346
347 # I already bought 3 / rate FOO, 6 / rate left
dd359bc0 348 else:
1aa7d4fa 349 base_currency = self.currency
c11e4274 350 # FOO
1aa7d4fa
IB
351 if self.action == "dispose":
352 filled = self.filled_amount(in_base_currency=True)
353 # Base is FOO
354
355 delta = (delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
356 - filled).in_currency(self.base_currency, self.market, rate=1/rate)
357 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
358 # computed at rate 1 Foo = 0.01 BTC
359 # Computation says I should sell it at 125 FOO / BTC
360 # -> delta_in_base = 9 BTC
361 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
362 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
363
364 # I already bought 300/125 BTC, only 600/125 left
365 else:
366 filled = self.filled_amount(in_base_currency=False)
367 # Base is FOO
368
369 delta = delta_in_base
370 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
371 # At rate 100 Foo / BTC
372 # Computation says I should buy it at 125 FOO / BTC
373 # -> delta_in_base = 9 BTC
374 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
375
376 delta = delta - filled
377 # I already sold 4 BTC, only 5 left
dd359bc0 378
2033e7fe
IB
379 if close_if_possible is None:
380 close_if_possible = (self.value_to == 0)
006a2084 381
1aa7d4fa 382 if delta <= 0:
f86ee140 383 self.market.report.log_error("prepare_order", message="Less to do than already filled: {}".format(delta))
5a72ded7 384 return None
80cdd672 385
186f7d81 386 order = Order(self.order_action(),
1aa7d4fa 387 delta, rate, base_currency, self.trade_type,
5a72ded7
IB
388 self.market, self, close_if_possible=close_if_possible)
389 self.orders.append(order)
390 return order
dd359bc0 391
3d0247f9
IB
392 def as_json(self):
393 return {
394 "action": self.action,
395 "from": self.value_from.as_json()["value"],
396 "to": self.value_to.as_json()["value"],
397 "currency": self.currency,
398 "base_currency": self.base_currency,
399 }
400
dd359bc0 401 def __repr__(self):
f9226903
IB
402 if self.closed and not self.is_fullfiled:
403 closed = " ❌"
404 elif self.is_fullfiled:
405 closed = " ✔"
406 else:
407 closed = ""
408
409 return "Trade({} -> {} in {}, {}{})".format(
dd359bc0
IB
410 self.value_from,
411 self.value_to,
412 self.currency,
f9226903
IB
413 self.action,
414 closed)
dd359bc0 415
3d0247f9 416 def print_with_order(self, ind=""):
f86ee140 417 self.market.report.print_log("{}{}".format(ind, self))
272b3cfb 418 for order in self.orders:
f86ee140 419 self.market.report.print_log("{}\t{}".format(ind, order))
c31df868 420 for mouvement in order.mouvements:
f86ee140 421 self.market.report.print_log("{}\t\t{}".format(ind, mouvement))
dd359bc0 422
7f3e7d27
IB
423class RetryException(Exception):
424 pass
425
272b3cfb 426class Order:
006a2084 427 def __init__(self, action, amount, rate, base_currency, trade_type, market,
80cdd672 428 trade, close_if_possible=False):
dd359bc0
IB
429 self.action = action
430 self.amount = amount
431 self.rate = rate
432 self.base_currency = base_currency
a9950fd0 433 self.market = market
350ed24d 434 self.trade_type = trade_type
80cdd672
IB
435 self.results = []
436 self.mouvements = []
a9950fd0 437 self.status = "pending"
80cdd672 438 self.trade = trade
006a2084 439 self.close_if_possible = close_if_possible
5a72ded7 440 self.id = None
f70bb858 441 self.tries = 0
7f3e7d27 442 self.start_date = None
dd359bc0 443
3d0247f9
IB
444 def as_json(self):
445 return {
446 "action": self.action,
447 "trade_type": self.trade_type,
448 "amount": self.amount.as_json()["value"],
449 "currency": self.amount.as_json()["currency"],
450 "base_currency": self.base_currency,
451 "rate": self.rate,
452 "status": self.status,
453 "close_if_possible": self.close_if_possible,
454 "id": self.id,
455 "mouvements": list(map(lambda x: x.as_json(), self.mouvements))
456 }
457
dd359bc0 458 def __repr__(self):
006a2084 459 return "Order({} {} {} at {} {} [{}]{})".format(
dd359bc0 460 self.action,
350ed24d 461 self.trade_type,
dd359bc0
IB
462 self.amount,
463 self.rate,
464 self.base_currency,
006a2084
IB
465 self.status,
466 " ✂" if self.close_if_possible else "",
dd359bc0
IB
467 )
468
350ed24d
IB
469 @property
470 def account(self):
471 if self.trade_type == "long":
472 return "exchange"
473 else:
474 return "margin"
475
5a72ded7
IB
476 @property
477 def open(self):
478 return self.status == "open"
479
a9950fd0
IB
480 @property
481 def pending(self):
482 return self.status == "pending"
483
484 @property
485 def finished(self):
d8deb0e9 486 return self.status.startswith("closed") or self.status == "canceled" or self.status == "error"
a9950fd0 487
b47d7b54 488 @retry((InsufficientFunds, RetryException, InvalidNonce))
80cdd672 489 def run(self):
f70bb858 490 self.tries += 1
dd359bc0 491 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
f86ee140 492 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
dd359bc0 493
7f3e7d27 494 action = "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol, self.action, amount, self.rate, self.account)
f86ee140 495 if self.market.debug:
7f3e7d27 496 self.market.report.log_debug_action(action)
80cdd672 497 self.results.append({"debug": True, "id": -1})
dd359bc0 498 else:
e7d7c0e5 499 self.start_date = datetime.datetime.now()
dd359bc0 500 try:
f86ee140 501 self.results.append(self.market.ccxt.create_order(symbol, 'limit', self.action, amount, price=self.rate, account=self.account))
f70bb858 502 except InvalidOrder:
df9e4e7f
IB
503 # Impossible to honor the order (dust amount)
504 self.status = "closed"
505 self.mark_finished_order()
506 return
b47d7b54
IB
507 except InvalidNonce as e:
508 if self.tries < 5:
509 self.market.report.log_error(action, message="Retrying after invalid nonce", exception=e)
510 raise e
511 else:
512 self.market.report.log_error(action, message="Giving up {} after invalid nonce".format(self), exception=e)
513 self.status = "error"
514 return
7f3e7d27
IB
515 except RequestTimeout as e:
516 if not self.retrieve_order():
517 if self.tries < 5:
518 self.market.report.log_error(action, message="Retrying after timeout", exception=e)
519 # We make a specific call in case retrieve_order
520 # would raise itself
521 raise RetryException
522 else:
523 self.market.report.log_error(action, message="Giving up {} after timeouts".format(self), exception=e)
524 self.status = "error"
525 return
526 else:
527 self.market.report.log_error(action, message="Timeout, found the order")
f70bb858
IB
528 except InsufficientFunds as e:
529 if self.tries < 5:
530 self.market.report.log_error(action, message="Retrying with reduced amount", exception=e)
531 self.amount = self.amount * D("0.99")
532 raise e
533 else:
534 self.market.report.log_error(action, message="Giving up {}".format(self), exception=e)
535 self.status = "error"
536 return
fd8afa51
IB
537 except Exception as e:
538 self.status = "error"
f86ee140 539 self.market.report.log_error(action, exception=e)
5a72ded7
IB
540 return
541 self.id = self.results[0]["id"]
542 self.status = "open"
dd359bc0 543
a9950fd0 544 def get_status(self):
f86ee140
IB
545 if self.market.debug:
546 self.market.report.log_debug_action("Getting {} status".format(self))
80cdd672 547 return self.status
dd359bc0 548 # other states are "closed" and "canceled"
5a72ded7 549 if not self.finished:
80cdd672 550 self.fetch()
dd359bc0
IB
551 return self.status
552
7ba831c5 553 def mark_disappeared_order(self):
5542e9e3 554 if self.status.startswith("closed") and \
7ba831c5
IB
555 len(self.mouvements) > 0 and \
556 self.mouvements[-1].total_in_base == 0:
5542e9e3 557 self.status = "error_disappeared"
5542e9e3 558
80cdd672 559 def mark_finished_order(self):
d8deb0e9 560 if self.status.startswith("closed") and self.market.debug:
f86ee140 561 self.market.report.log_debug_action("Mark {} as finished".format(self))
80cdd672 562 return
d8deb0e9 563 if self.status.startswith("closed"):
006a2084 564 if self.trade_type == "short" and self.action == "buy" and self.close_if_possible:
f86ee140 565 self.market.ccxt.close_margin_position(self.amount.currency, self.base_currency)
006a2084 566
aca4d437 567 def fetch(self):
f86ee140
IB
568 if self.market.debug:
569 self.market.report.log_debug_action("Fetching {}".format(self))
3d0247f9 570 return
aca4d437 571 try:
f9226903 572 result = self.market.ccxt.fetch_order(self.id)
aca4d437
IB
573 self.results.append(result)
574 self.status = result["status"]
575 # Time at which the order started
576 self.timestamp = result["datetime"]
577 except OrderNotCached:
578 self.status = "closed_unknown"
5a72ded7 579
80cdd672
IB
580 self.fetch_mouvements()
581
7ba831c5 582 self.mark_disappeared_order()
5542e9e3 583
d8deb0e9 584 self.mark_finished_order()
80cdd672
IB
585 # FIXME: consider open order with dust remaining as closed
586
80cdd672 587 def dust_amount_remaining(self):
5a72ded7 588 return self.remaining_amount() < Amount(self.amount.currency, D("0.001"))
80cdd672 589
80cdd672 590 def remaining_amount(self):
1aa7d4fa 591 return self.amount - self.filled_amount()
80cdd672 592
1aa7d4fa 593 def filled_amount(self, in_base_currency=False):
80cdd672
IB
594 if self.status == "open":
595 self.fetch()
1aa7d4fa 596 filled_amount = 0
80cdd672 597 for mouvement in self.mouvements:
1aa7d4fa
IB
598 if in_base_currency:
599 filled_amount += mouvement.total_in_base
600 else:
601 filled_amount += mouvement.total
80cdd672
IB
602 return filled_amount
603
604 def fetch_mouvements(self):
df9e4e7f 605 try:
f86ee140 606 mouvements = self.market.ccxt.privatePostReturnOrderTrades({"orderNumber": self.id})
df9e4e7f
IB
607 except ExchangeError:
608 mouvements = []
80cdd672
IB
609 self.mouvements = []
610
611 for mouvement_hash in mouvements:
612 self.mouvements.append(Mouvement(self.amount.currency,
613 self.base_currency, mouvement_hash))
7ba831c5 614 self.mouvements.sort(key= lambda x: x.date)
006a2084 615
272b3cfb 616 def cancel(self):
f86ee140
IB
617 if self.market.debug:
618 self.market.report.log_debug_action("Mark {} as cancelled".format(self))
80cdd672
IB
619 self.status = "canceled"
620 return
f9226903
IB
621 if self.open and self.id is not None:
622 try:
623 self.market.ccxt.cancel_order(self.id)
624 except OrderNotFound as e: # Closed inbetween
625 self.market.report.log_error("cancel_order", message="Already cancelled order", exception=e)
626 self.fetch()
80cdd672 627
7f3e7d27
IB
628 def retrieve_order(self):
629 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
630 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
631 start_timestamp = self.start_date.timestamp() - 5
632
633 similar_open_orders = self.market.ccxt.fetch_orders(symbol=symbol, since=start_timestamp)
634 for order in similar_open_orders:
635 if (order["info"]["margin"] == 1 and self.account == "exchange") or\
636 (order["info"]["margin"] != 1 and self.account == "margin"):
637 i_m_tested = True # coverage bug ?!
638 continue
639 if order["info"]["side"] != self.action:
640 continue
641 amount_diff = round(
642 abs(D(order["info"]["startingAmount"]) - amount),
643 self.market.ccxt.order_precision(symbol))
644 rate_diff = round(
645 abs(D(order["info"]["rate"]) - self.rate),
646 self.market.ccxt.order_precision(symbol))
647 if amount_diff != 0 or rate_diff != 0:
648 continue
649 self.results.append({"id": order["id"]})
650 return True
651
652 similar_trades = self.market.ccxt.fetch_my_trades(symbol=symbol, since=start_timestamp)
653 # FIXME: use set instead of sorted(list(...))
654 for order_id in sorted(list(map(lambda x: x["order"], similar_trades))):
655 trades = list(filter(lambda x: x["order"] == order_id, similar_trades))
656 if any(x["timestamp"] < start_timestamp for x in trades):
657 continue
658 if any(x["side"] != self.action for x in trades):
659 continue
660 if any(x["info"]["category"] == "exchange" and self.account == "margin" for x in trades) or\
661 any(x["info"]["category"] == "marginTrade" and self.account == "exchange" for x in trades):
662 continue
663 trade_sum = sum(D(x["info"]["amount"]) for x in trades)
664 amount_diff = round(abs(trade_sum - amount),
665 self.market.ccxt.order_precision(symbol))
666 if amount_diff != 0:
667 continue
668 if (self.action == "sell" and any(D(x["info"]["rate"]) < self.rate for x in trades)) or\
669 (self.action == "buy" and any(D(x["info"]["rate"]) > self.rate for x in trades)):
670 continue
671 self.results.append({"id": order_id})
672 return True
673
674 return False
675
80cdd672
IB
676class Mouvement:
677 def __init__(self, currency, base_currency, hash_):
678 self.currency = currency
679 self.base_currency = base_currency
df9e4e7f
IB
680 self.id = hash_.get("tradeID")
681 self.action = hash_.get("type")
682 self.fee_rate = D(hash_.get("fee", -1))
683 try:
e7d7c0e5 684 self.date = datetime.datetime.strptime(hash_.get("date", ""), '%Y-%m-%d %H:%M:%S')
df9e4e7f
IB
685 except ValueError:
686 self.date = None
687 self.rate = D(hash_.get("rate", 0))
688 self.total = Amount(currency, hash_.get("amount", 0))
80cdd672 689 # rate * total = total_in_base
df9e4e7f 690 self.total_in_base = Amount(base_currency, hash_.get("total", 0))
272b3cfb 691
3d0247f9
IB
692 def as_json(self):
693 return {
694 "fee_rate": self.fee_rate,
695 "date": self.date,
696 "action": self.action,
697 "total": self.total.value,
698 "currency": self.currency,
699 "total_in_base": self.total_in_base.value,
700 "base_currency": self.base_currency
701 }
702
c31df868
IB
703 def __repr__(self):
704 if self.fee_rate > 0:
705 fee_rate = " fee: {}%".format(self.fee_rate * 100)
706 else:
707 fee_rate = ""
708 if self.date is None:
709 date = "No date"
710 else:
711 date = self.date
712 return "Mouvement({} ; {} {} ({}){})".format(
713 date, self.action, self.total, self.total_in_base,
714 fee_rate)
715