]> git.immae.eu Git - perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git/blame - portfolio.py
Add acceptance tests
[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)
186f7d81
IB
314 self.inverted = ticker["inverted"]
315 if self.inverted:
f2097d71 316 ticker = ticker["original"]
186f7d81 317 rate = Computation.compute_value(ticker, self.order_action(), compute_value=compute_value)
f2097d71 318
97922ff1
IB
319 # FIXME: Dust amount should be removed from there if they werent
320 # honored in other sales
aca4d437 321 delta_in_base = abs(self.delta)
c11e4274 322 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
dd359bc0 323
186f7d81 324 if not self.inverted:
1aa7d4fa 325 base_currency = self.base_currency
350ed24d 326 # BTC
006a2084 327 if self.action == "dispose":
1aa7d4fa
IB
328 filled = self.filled_amount(in_base_currency=False)
329 delta = delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
330 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
331 # worth of it, computed first with rate 10 FOO = 1 BTC.
332 # -> I "sell" "90" FOO at proposed rate "rate".
333
334 delta = delta - filled
335 # I already sold 60 FOO, 30 left
f2097d71 336 else:
1aa7d4fa
IB
337 filled = self.filled_amount(in_base_currency=True)
338 delta = (delta_in_base - filled).in_currency(self.currency, self.market, rate=1/rate)
339 # I want to buy 9 BTC worth of FOO, computed with rate
340 # 10 FOO = 1 BTC
341 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
342
343 # I already bought 3 / rate FOO, 6 / rate left
dd359bc0 344 else:
1aa7d4fa 345 base_currency = self.currency
c11e4274 346 # FOO
1aa7d4fa
IB
347 if self.action == "dispose":
348 filled = self.filled_amount(in_base_currency=True)
349 # Base is FOO
350
351 delta = (delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
352 - filled).in_currency(self.base_currency, self.market, rate=1/rate)
353 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
354 # computed at rate 1 Foo = 0.01 BTC
355 # Computation says I should sell it at 125 FOO / BTC
356 # -> delta_in_base = 9 BTC
357 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
358 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
359
360 # I already bought 300/125 BTC, only 600/125 left
361 else:
362 filled = self.filled_amount(in_base_currency=False)
363 # Base is FOO
364
365 delta = delta_in_base
366 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
367 # At rate 100 Foo / BTC
368 # Computation says I should buy it at 125 FOO / BTC
369 # -> delta_in_base = 9 BTC
370 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
371
372 delta = delta - filled
373 # I already sold 4 BTC, only 5 left
dd359bc0 374
2033e7fe
IB
375 if close_if_possible is None:
376 close_if_possible = (self.value_to == 0)
006a2084 377
1aa7d4fa 378 if delta <= 0:
f86ee140 379 self.market.report.log_error("prepare_order", message="Less to do than already filled: {}".format(delta))
5a72ded7 380 return None
80cdd672 381
186f7d81 382 order = Order(self.order_action(),
1aa7d4fa 383 delta, rate, base_currency, self.trade_type,
5a72ded7
IB
384 self.market, self, close_if_possible=close_if_possible)
385 self.orders.append(order)
386 return order
dd359bc0 387
3d0247f9
IB
388 def as_json(self):
389 return {
390 "action": self.action,
391 "from": self.value_from.as_json()["value"],
392 "to": self.value_to.as_json()["value"],
393 "currency": self.currency,
394 "base_currency": self.base_currency,
395 }
396
dd359bc0 397 def __repr__(self):
f9226903
IB
398 if self.closed and not self.is_fullfiled:
399 closed = " ❌"
400 elif self.is_fullfiled:
401 closed = " ✔"
402 else:
403 closed = ""
404
405 return "Trade({} -> {} in {}, {}{})".format(
dd359bc0
IB
406 self.value_from,
407 self.value_to,
408 self.currency,
f9226903
IB
409 self.action,
410 closed)
dd359bc0 411
3d0247f9 412 def print_with_order(self, ind=""):
f86ee140 413 self.market.report.print_log("{}{}".format(ind, self))
272b3cfb 414 for order in self.orders:
f86ee140 415 self.market.report.print_log("{}\t{}".format(ind, order))
c31df868 416 for mouvement in order.mouvements:
f86ee140 417 self.market.report.print_log("{}\t\t{}".format(ind, mouvement))
dd359bc0 418
7f3e7d27
IB
419class RetryException(Exception):
420 pass
421
272b3cfb 422class Order:
006a2084 423 def __init__(self, action, amount, rate, base_currency, trade_type, market,
80cdd672 424 trade, close_if_possible=False):
dd359bc0
IB
425 self.action = action
426 self.amount = amount
427 self.rate = rate
428 self.base_currency = base_currency
a9950fd0 429 self.market = market
350ed24d 430 self.trade_type = trade_type
80cdd672
IB
431 self.results = []
432 self.mouvements = []
a9950fd0 433 self.status = "pending"
80cdd672 434 self.trade = trade
006a2084 435 self.close_if_possible = close_if_possible
5a72ded7 436 self.id = None
f70bb858 437 self.tries = 0
7f3e7d27 438 self.start_date = None
dd359bc0 439
3d0247f9
IB
440 def as_json(self):
441 return {
442 "action": self.action,
443 "trade_type": self.trade_type,
444 "amount": self.amount.as_json()["value"],
445 "currency": self.amount.as_json()["currency"],
446 "base_currency": self.base_currency,
447 "rate": self.rate,
448 "status": self.status,
449 "close_if_possible": self.close_if_possible,
450 "id": self.id,
451 "mouvements": list(map(lambda x: x.as_json(), self.mouvements))
452 }
453
dd359bc0 454 def __repr__(self):
006a2084 455 return "Order({} {} {} at {} {} [{}]{})".format(
dd359bc0 456 self.action,
350ed24d 457 self.trade_type,
dd359bc0
IB
458 self.amount,
459 self.rate,
460 self.base_currency,
006a2084
IB
461 self.status,
462 " ✂" if self.close_if_possible else "",
dd359bc0
IB
463 )
464
350ed24d
IB
465 @property
466 def account(self):
467 if self.trade_type == "long":
468 return "exchange"
469 else:
470 return "margin"
471
5a72ded7
IB
472 @property
473 def open(self):
474 return self.status == "open"
475
a9950fd0
IB
476 @property
477 def pending(self):
478 return self.status == "pending"
479
480 @property
481 def finished(self):
d8deb0e9 482 return self.status.startswith("closed") or self.status == "canceled" or self.status == "error"
a9950fd0 483
b47d7b54 484 @retry((InsufficientFunds, RetryException, InvalidNonce))
80cdd672 485 def run(self):
f70bb858 486 self.tries += 1
dd359bc0 487 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
f86ee140 488 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
dd359bc0 489
7f3e7d27 490 action = "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol, self.action, amount, self.rate, self.account)
f86ee140 491 if self.market.debug:
7f3e7d27 492 self.market.report.log_debug_action(action)
80cdd672 493 self.results.append({"debug": True, "id": -1})
dd359bc0 494 else:
e7d7c0e5 495 self.start_date = datetime.datetime.now()
dd359bc0 496 try:
f86ee140 497 self.results.append(self.market.ccxt.create_order(symbol, 'limit', self.action, amount, price=self.rate, account=self.account))
f70bb858 498 except InvalidOrder:
df9e4e7f
IB
499 # Impossible to honor the order (dust amount)
500 self.status = "closed"
501 self.mark_finished_order()
502 return
b47d7b54
IB
503 except InvalidNonce as e:
504 if self.tries < 5:
505 self.market.report.log_error(action, message="Retrying after invalid nonce", exception=e)
506 raise e
507 else:
508 self.market.report.log_error(action, message="Giving up {} after invalid nonce".format(self), exception=e)
509 self.status = "error"
510 return
7f3e7d27
IB
511 except RequestTimeout as e:
512 if not self.retrieve_order():
513 if self.tries < 5:
514 self.market.report.log_error(action, message="Retrying after timeout", exception=e)
515 # We make a specific call in case retrieve_order
516 # would raise itself
517 raise RetryException
518 else:
519 self.market.report.log_error(action, message="Giving up {} after timeouts".format(self), exception=e)
520 self.status = "error"
521 return
522 else:
523 self.market.report.log_error(action, message="Timeout, found the order")
f70bb858
IB
524 except InsufficientFunds as e:
525 if self.tries < 5:
526 self.market.report.log_error(action, message="Retrying with reduced amount", exception=e)
527 self.amount = self.amount * D("0.99")
528 raise e
529 else:
530 self.market.report.log_error(action, message="Giving up {}".format(self), exception=e)
531 self.status = "error"
532 return
fd8afa51
IB
533 except Exception as e:
534 self.status = "error"
f86ee140 535 self.market.report.log_error(action, exception=e)
5a72ded7
IB
536 return
537 self.id = self.results[0]["id"]
538 self.status = "open"
dd359bc0 539
a9950fd0 540 def get_status(self):
f86ee140
IB
541 if self.market.debug:
542 self.market.report.log_debug_action("Getting {} status".format(self))
80cdd672 543 return self.status
dd359bc0 544 # other states are "closed" and "canceled"
5a72ded7 545 if not self.finished:
80cdd672 546 self.fetch()
dd359bc0
IB
547 return self.status
548
7ba831c5 549 def mark_disappeared_order(self):
5542e9e3 550 if self.status.startswith("closed") and \
7ba831c5
IB
551 len(self.mouvements) > 0 and \
552 self.mouvements[-1].total_in_base == 0:
5542e9e3 553 self.status = "error_disappeared"
5542e9e3 554
80cdd672 555 def mark_finished_order(self):
d8deb0e9 556 if self.status.startswith("closed") and self.market.debug:
f86ee140 557 self.market.report.log_debug_action("Mark {} as finished".format(self))
80cdd672 558 return
d8deb0e9 559 if self.status.startswith("closed"):
006a2084 560 if self.trade_type == "short" and self.action == "buy" and self.close_if_possible:
f86ee140 561 self.market.ccxt.close_margin_position(self.amount.currency, self.base_currency)
006a2084 562
aca4d437 563 def fetch(self):
f86ee140
IB
564 if self.market.debug:
565 self.market.report.log_debug_action("Fetching {}".format(self))
3d0247f9 566 return
aca4d437 567 try:
f9226903 568 result = self.market.ccxt.fetch_order(self.id)
aca4d437
IB
569 self.results.append(result)
570 self.status = result["status"]
571 # Time at which the order started
572 self.timestamp = result["datetime"]
573 except OrderNotCached:
574 self.status = "closed_unknown"
5a72ded7 575
80cdd672
IB
576 self.fetch_mouvements()
577
7ba831c5 578 self.mark_disappeared_order()
5542e9e3 579
d8deb0e9 580 self.mark_finished_order()
80cdd672
IB
581 # FIXME: consider open order with dust remaining as closed
582
80cdd672 583 def dust_amount_remaining(self):
5a72ded7 584 return self.remaining_amount() < Amount(self.amount.currency, D("0.001"))
80cdd672 585
80cdd672 586 def remaining_amount(self):
1aa7d4fa 587 return self.amount - self.filled_amount()
80cdd672 588
1aa7d4fa 589 def filled_amount(self, in_base_currency=False):
80cdd672
IB
590 if self.status == "open":
591 self.fetch()
1aa7d4fa 592 filled_amount = 0
80cdd672 593 for mouvement in self.mouvements:
1aa7d4fa
IB
594 if in_base_currency:
595 filled_amount += mouvement.total_in_base
596 else:
597 filled_amount += mouvement.total
80cdd672
IB
598 return filled_amount
599
600 def fetch_mouvements(self):
df9e4e7f 601 try:
f86ee140 602 mouvements = self.market.ccxt.privatePostReturnOrderTrades({"orderNumber": self.id})
df9e4e7f
IB
603 except ExchangeError:
604 mouvements = []
80cdd672
IB
605 self.mouvements = []
606
607 for mouvement_hash in mouvements:
608 self.mouvements.append(Mouvement(self.amount.currency,
609 self.base_currency, mouvement_hash))
7ba831c5 610 self.mouvements.sort(key= lambda x: x.date)
006a2084 611
272b3cfb 612 def cancel(self):
f86ee140
IB
613 if self.market.debug:
614 self.market.report.log_debug_action("Mark {} as cancelled".format(self))
80cdd672
IB
615 self.status = "canceled"
616 return
f9226903
IB
617 if self.open and self.id is not None:
618 try:
619 self.market.ccxt.cancel_order(self.id)
620 except OrderNotFound as e: # Closed inbetween
621 self.market.report.log_error("cancel_order", message="Already cancelled order", exception=e)
622 self.fetch()
80cdd672 623
7f3e7d27
IB
624 def retrieve_order(self):
625 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
626 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
627 start_timestamp = self.start_date.timestamp() - 5
628
629 similar_open_orders = self.market.ccxt.fetch_orders(symbol=symbol, since=start_timestamp)
630 for order in similar_open_orders:
631 if (order["info"]["margin"] == 1 and self.account == "exchange") or\
632 (order["info"]["margin"] != 1 and self.account == "margin"):
633 i_m_tested = True # coverage bug ?!
634 continue
635 if order["info"]["side"] != self.action:
636 continue
637 amount_diff = round(
638 abs(D(order["info"]["startingAmount"]) - amount),
639 self.market.ccxt.order_precision(symbol))
640 rate_diff = round(
641 abs(D(order["info"]["rate"]) - self.rate),
642 self.market.ccxt.order_precision(symbol))
643 if amount_diff != 0 or rate_diff != 0:
644 continue
645 self.results.append({"id": order["id"]})
646 return True
647
648 similar_trades = self.market.ccxt.fetch_my_trades(symbol=symbol, since=start_timestamp)
649 # FIXME: use set instead of sorted(list(...))
650 for order_id in sorted(list(map(lambda x: x["order"], similar_trades))):
651 trades = list(filter(lambda x: x["order"] == order_id, similar_trades))
652 if any(x["timestamp"] < start_timestamp for x in trades):
653 continue
654 if any(x["side"] != self.action for x in trades):
655 continue
656 if any(x["info"]["category"] == "exchange" and self.account == "margin" for x in trades) or\
657 any(x["info"]["category"] == "marginTrade" and self.account == "exchange" for x in trades):
658 continue
659 trade_sum = sum(D(x["info"]["amount"]) for x in trades)
660 amount_diff = round(abs(trade_sum - amount),
661 self.market.ccxt.order_precision(symbol))
662 if amount_diff != 0:
663 continue
664 if (self.action == "sell" and any(D(x["info"]["rate"]) < self.rate for x in trades)) or\
665 (self.action == "buy" and any(D(x["info"]["rate"]) > self.rate for x in trades)):
666 continue
667 self.results.append({"id": order_id})
668 return True
669
670 return False
671
80cdd672
IB
672class Mouvement:
673 def __init__(self, currency, base_currency, hash_):
674 self.currency = currency
675 self.base_currency = base_currency
df9e4e7f
IB
676 self.id = hash_.get("tradeID")
677 self.action = hash_.get("type")
678 self.fee_rate = D(hash_.get("fee", -1))
679 try:
e7d7c0e5 680 self.date = datetime.datetime.strptime(hash_.get("date", ""), '%Y-%m-%d %H:%M:%S')
df9e4e7f
IB
681 except ValueError:
682 self.date = None
683 self.rate = D(hash_.get("rate", 0))
684 self.total = Amount(currency, hash_.get("amount", 0))
80cdd672 685 # rate * total = total_in_base
df9e4e7f 686 self.total_in_base = Amount(base_currency, hash_.get("total", 0))
272b3cfb 687
3d0247f9
IB
688 def as_json(self):
689 return {
690 "fee_rate": self.fee_rate,
691 "date": self.date,
692 "action": self.action,
693 "total": self.total.value,
694 "currency": self.currency,
695 "total_in_base": self.total_in_base.value,
696 "base_currency": self.base_currency
697 }
698
c31df868
IB
699 def __repr__(self):
700 if self.fee_rate > 0:
701 fee_rate = " fee: {}%".format(self.fee_rate * 100)
702 else:
703 fee_rate = ""
704 if self.date is None:
705 date = "No date"
706 else:
707 date = self.date
708 return "Mouvement({} ; {} {} ({}){})".format(
709 date, self.action, self.total, self.total_in_base,
710 fee_rate)
711