]> git.immae.eu Git - perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git/blob - portfolio.py
Merge branch 'night_fixes' into dev
[perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git] / portfolio.py
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
5
6 # FIXME: correctly handle web call timeouts
7
8 class Computation:
9 computations = {
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"],
14 }
15
16 @classmethod
17 def compute_value(cls, ticker, action, compute_value="default"):
18 if action == "buy":
19 action = "ask"
20 if action == "sell":
21 action = "bid"
22 if isinstance(compute_value, str):
23 compute_value = cls.computations[compute_value]
24 return compute_value(ticker, action)
25
26 class Amount:
27 def __init__(self, currency, value, linked_to=None, ticker=None, rate=None):
28 self.currency = currency
29 self.value = D(value)
30 self.linked_to = linked_to
31 self.ticker = ticker
32 self.rate = rate
33
34 def in_currency(self, other_currency, market, rate=None, action=None, compute_value="average"):
35 if other_currency == self.currency:
36 return self
37 if rate is not None:
38 return Amount(
39 other_currency,
40 self.value * rate,
41 linked_to=self,
42 rate=rate)
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)
46 return Amount(
47 other_currency,
48 self.value * rate,
49 linked_to=self,
50 ticker=asset_ticker,
51 rate=rate)
52 else:
53 raise Exception("This asset is not available in the chosen market")
54
55 def as_json(self):
56 return {
57 "currency": self.currency,
58 "value": round(self).value.normalize(),
59 }
60
61 def __round__(self, n=8):
62 return Amount(self.currency, self.value.quantize(D(1)/D(10**n), rounding=ROUND_DOWN))
63
64 def __abs__(self):
65 return Amount(self.currency, abs(self.value))
66
67 def __add__(self, other):
68 if other == 0:
69 return self
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)
73
74 def __radd__(self, other):
75 if other == 0:
76 return self
77 else:
78 return self.__add__(other)
79
80 def __sub__(self, other):
81 if other == 0:
82 return self
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)
86
87 def __rsub__(self, other):
88 if other == 0:
89 return -self
90 else:
91 return -self.__sub__(other)
92
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)
97
98 def __rmul__(self, value):
99 return self.__mul__(value)
100
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)
105
106 def __truediv__(self, value):
107 return self.__floordiv__(value)
108
109 def __lt__(self, other):
110 if other == 0:
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
115
116 def __le__(self, other):
117 return self == other or self < other
118
119 def __gt__(self, other):
120 return not self <= other
121
122 def __ge__(self, other):
123 return not self < other
124
125 def __eq__(self, other):
126 if other == 0:
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
131
132 def __ne__(self, other):
133 return not self == other
134
135 def __neg__(self):
136 return Amount(self.currency, - self.value)
137
138 def __str__(self):
139 if self.linked_to is None:
140 return "{:.8f} {}".format(self.value, self.currency)
141 else:
142 return "{:.8f} {} [{}]".format(self.value, self.currency, self.linked_to)
143
144 def __repr__(self):
145 if self.linked_to is None:
146 return "Amount({:.8f} {})".format(self.value, self.currency)
147 else:
148 return "Amount({:.8f} {} -> {})".format(self.value, self.currency, repr(self.linked_to))
149
150 class Balance:
151 base_keys = ["total", "exchange_total", "exchange_used",
152 "exchange_free", "margin_total", "margin_in_position",
153 "margin_available", "margin_borrowed", "margin_pending_gain"]
154
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)))
159
160 self.margin_position_type = hash_.get("margin_position_type")
161
162 if hash_.get("margin_borrowed_base_currency") is not None:
163 base_currency = hash_["margin_borrowed_base_currency"]
164 for key in [
165 "margin_liquidation_price",
166 "margin_lending_fees",
167 "margin_pending_base_gain",
168 "margin_borrowed_base_price"
169 ]:
170 setattr(self, key, Amount(base_currency, hash_.get(key, 0)))
171
172 def as_json(self):
173 return dict(map(lambda x: (x, getattr(self, x).as_json()["value"]), self.base_keys))
174
175 def __repr__(self):
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))
181 else:
182 exchange = " Exch: [❌{}]".format(str(self.exchange_used))
183 else:
184 exchange = ""
185
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))
191 else:
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))
197 else:
198 margin = ""
199
200 if self.margin_total != 0 and self.exchange_total != 0:
201 total = " Total: [{}]".format(str(self.total))
202 else:
203 total = ""
204
205 return "Balance({}".format(self.currency) + "".join([exchange, margin, total]) + ")"
206
207 class Trade:
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
214 self.orders = []
215 self.market = market
216 self.closed = False
217 self.inverted = None
218 assert self.value_from.value * self.value_to.value >= 0
219 assert self.value_from.currency == self.value_to.currency
220 if self.value_from != 0:
221 assert self.value_from.linked_to is not None and self.value_from.linked_to.currency == self.currency
222 elif self.value_from.linked_to is None:
223 self.value_from.linked_to = Amount(self.currency, 0)
224 self.base_currency = self.value_from.currency
225
226 @property
227 def delta(self):
228 return self.value_to - self.value_from
229
230 @property
231 def action(self):
232 if self.value_from == self.value_to:
233 return None
234 if self.base_currency == self.currency:
235 return None
236
237 if abs(self.value_from) < abs(self.value_to):
238 return "acquire"
239 else:
240 return "dispose"
241
242 def order_action(self):
243 if (self.value_from < self.value_to) != self.inverted:
244 return "buy"
245 else:
246 return "sell"
247
248 @property
249 def trade_type(self):
250 if self.value_from + self.value_to < 0:
251 return "short"
252 else:
253 return "long"
254
255 @property
256 def pending(self):
257 return not (self.is_fullfiled or self.closed)
258
259 def close(self):
260 for order in self.orders:
261 order.cancel()
262 self.closed = True
263
264 @property
265 def is_fullfiled(self):
266 return abs(self.filled_amount(in_base_currency=(not self.inverted))) >= abs(self.delta)
267
268 def filled_amount(self, in_base_currency=False):
269 filled_amount = 0
270 for order in self.orders:
271 filled_amount += order.filled_amount(in_base_currency=in_base_currency)
272 return filled_amount
273
274 def update_order(self, order, tick):
275 actions = {
276 0: ["waiting", None],
277 1: ["waiting", None],
278 2: ["adjusting", lambda x, y: (x[y] + x["average"]) / 2],
279 3: ["waiting", None],
280 4: ["waiting", None],
281 5: ["adjusting", lambda x, y: (x[y]*2 + x["average"]) / 3],
282 6: ["waiting", None],
283 7: ["market_fallback", "default"],
284 }
285
286 if tick in actions:
287 update, compute_value = actions[tick]
288 elif tick % 3 == 1:
289 update = "market_adjust"
290 compute_value = "default"
291 else:
292 update = "waiting"
293 compute_value = None
294
295 if compute_value is not None:
296 order.cancel()
297 new_order = self.prepare_order(compute_value=compute_value)
298 else:
299 new_order = None
300
301 self.market.report.log_order(order, tick, update=update,
302 compute_value=compute_value, new_order=new_order)
303
304 if new_order is not None:
305 new_order.run()
306 self.market.report.log_order(order, tick, new_order=new_order)
307
308 def prepare_order(self, close_if_possible=None, compute_value="default"):
309 if self.action is None:
310 return None
311 ticker = self.market.get_ticker(self.currency, self.base_currency)
312 self.inverted = ticker["inverted"]
313 if self.inverted:
314 ticker = ticker["original"]
315 rate = Computation.compute_value(ticker, self.order_action(), compute_value=compute_value)
316
317 # FIXME: Dust amount should be removed from there if they werent
318 # honored in other sales
319 delta_in_base = abs(self.delta)
320 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
321
322 if not self.inverted:
323 base_currency = self.base_currency
324 # BTC
325 if self.action == "dispose":
326 filled = self.filled_amount(in_base_currency=False)
327 delta = delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
328 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
329 # worth of it, computed first with rate 10 FOO = 1 BTC.
330 # -> I "sell" "90" FOO at proposed rate "rate".
331
332 delta = delta - filled
333 # I already sold 60 FOO, 30 left
334 else:
335 filled = self.filled_amount(in_base_currency=True)
336 delta = (delta_in_base - filled).in_currency(self.currency, self.market, rate=1/rate)
337 # I want to buy 9 BTC worth of FOO, computed with rate
338 # 10 FOO = 1 BTC
339 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
340
341 # I already bought 3 / rate FOO, 6 / rate left
342 else:
343 base_currency = self.currency
344 # FOO
345 if self.action == "dispose":
346 filled = self.filled_amount(in_base_currency=True)
347 # Base is FOO
348
349 delta = (delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
350 - filled).in_currency(self.base_currency, self.market, rate=1/rate)
351 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
352 # computed at rate 1 Foo = 0.01 BTC
353 # Computation says I should sell it at 125 FOO / BTC
354 # -> delta_in_base = 9 BTC
355 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
356 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
357
358 # I already bought 300/125 BTC, only 600/125 left
359 else:
360 filled = self.filled_amount(in_base_currency=False)
361 # Base is FOO
362
363 delta = delta_in_base
364 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
365 # At rate 100 Foo / BTC
366 # Computation says I should buy it at 125 FOO / BTC
367 # -> delta_in_base = 9 BTC
368 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
369
370 delta = delta - filled
371 # I already sold 4 BTC, only 5 left
372
373 if close_if_possible is None:
374 close_if_possible = (self.value_to == 0)
375
376 if delta <= 0:
377 self.market.report.log_error("prepare_order", message="Less to do than already filled: {}".format(delta))
378 return None
379
380 order = Order(self.order_action(),
381 delta, rate, base_currency, self.trade_type,
382 self.market, self, close_if_possible=close_if_possible)
383 self.orders.append(order)
384 return order
385
386 def as_json(self):
387 return {
388 "action": self.action,
389 "from": self.value_from.as_json()["value"],
390 "to": self.value_to.as_json()["value"],
391 "currency": self.currency,
392 "base_currency": self.base_currency,
393 }
394
395 def __repr__(self):
396 if self.closed and not self.is_fullfiled:
397 closed = " ❌"
398 elif self.is_fullfiled:
399 closed = " ✔"
400 else:
401 closed = ""
402
403 return "Trade({} -> {} in {}, {}{})".format(
404 self.value_from,
405 self.value_to,
406 self.currency,
407 self.action,
408 closed)
409
410 def print_with_order(self, ind=""):
411 self.market.report.print_log("{}{}".format(ind, self))
412 for order in self.orders:
413 self.market.report.print_log("{}\t{}".format(ind, order))
414 for mouvement in order.mouvements:
415 self.market.report.print_log("{}\t\t{}".format(ind, mouvement))
416
417 class Order:
418 def __init__(self, action, amount, rate, base_currency, trade_type, market,
419 trade, close_if_possible=False):
420 self.action = action
421 self.amount = amount
422 self.rate = rate
423 self.base_currency = base_currency
424 self.market = market
425 self.trade_type = trade_type
426 self.results = []
427 self.mouvements = []
428 self.status = "pending"
429 self.trade = trade
430 self.close_if_possible = close_if_possible
431 self.id = None
432 self.tries = 0
433
434 def as_json(self):
435 return {
436 "action": self.action,
437 "trade_type": self.trade_type,
438 "amount": self.amount.as_json()["value"],
439 "currency": self.amount.as_json()["currency"],
440 "base_currency": self.base_currency,
441 "rate": self.rate,
442 "status": self.status,
443 "close_if_possible": self.close_if_possible,
444 "id": self.id,
445 "mouvements": list(map(lambda x: x.as_json(), self.mouvements))
446 }
447
448 def __repr__(self):
449 return "Order({} {} {} at {} {} [{}]{})".format(
450 self.action,
451 self.trade_type,
452 self.amount,
453 self.rate,
454 self.base_currency,
455 self.status,
456 " ✂" if self.close_if_possible else "",
457 )
458
459 @property
460 def account(self):
461 if self.trade_type == "long":
462 return "exchange"
463 else:
464 return "margin"
465
466 @property
467 def open(self):
468 return self.status == "open"
469
470 @property
471 def pending(self):
472 return self.status == "pending"
473
474 @property
475 def finished(self):
476 return self.status.startswith("closed") or self.status == "canceled" or self.status == "error"
477
478 @retry(InsufficientFunds)
479 def run(self):
480 self.tries += 1
481 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
482 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
483
484 if self.market.debug:
485 self.market.report.log_debug_action("market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(
486 symbol, self.action, amount, self.rate, self.account))
487 self.results.append({"debug": True, "id": -1})
488 else:
489 action = "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol, self.action, amount, self.rate, self.account)
490 try:
491 self.results.append(self.market.ccxt.create_order(symbol, 'limit', self.action, amount, price=self.rate, account=self.account))
492 except InvalidOrder:
493 # Impossible to honor the order (dust amount)
494 self.status = "closed"
495 self.mark_finished_order()
496 return
497 except InsufficientFunds as e:
498 if self.tries < 5:
499 self.market.report.log_error(action, message="Retrying with reduced amount", exception=e)
500 self.amount = self.amount * D("0.99")
501 raise e
502 else:
503 self.market.report.log_error(action, message="Giving up {}".format(self), exception=e)
504 self.status = "error"
505 return
506 except Exception as e:
507 self.status = "error"
508 self.market.report.log_error(action, exception=e)
509 return
510 self.id = self.results[0]["id"]
511 self.status = "open"
512
513 def get_status(self):
514 if self.market.debug:
515 self.market.report.log_debug_action("Getting {} status".format(self))
516 return self.status
517 # other states are "closed" and "canceled"
518 if not self.finished:
519 self.fetch()
520 return self.status
521
522 def mark_finished_order(self):
523 if self.status.startswith("closed") and self.market.debug:
524 self.market.report.log_debug_action("Mark {} as finished".format(self))
525 return
526 if self.status.startswith("closed"):
527 if self.trade_type == "short" and self.action == "buy" and self.close_if_possible:
528 self.market.ccxt.close_margin_position(self.amount.currency, self.base_currency)
529
530 def fetch(self):
531 if self.market.debug:
532 self.market.report.log_debug_action("Fetching {}".format(self))
533 return
534 try:
535 result = self.market.ccxt.fetch_order(self.id)
536 self.results.append(result)
537 self.status = result["status"]
538 # Time at which the order started
539 self.timestamp = result["datetime"]
540 except OrderNotCached:
541 self.status = "closed_unknown"
542
543 self.fetch_mouvements()
544
545 self.mark_finished_order()
546 # FIXME: consider open order with dust remaining as closed
547
548 def dust_amount_remaining(self):
549 return self.remaining_amount() < Amount(self.amount.currency, D("0.001"))
550
551 def remaining_amount(self):
552 return self.amount - self.filled_amount()
553
554 def filled_amount(self, in_base_currency=False):
555 if self.status == "open":
556 self.fetch()
557 filled_amount = 0
558 for mouvement in self.mouvements:
559 if in_base_currency:
560 filled_amount += mouvement.total_in_base
561 else:
562 filled_amount += mouvement.total
563 return filled_amount
564
565 def fetch_mouvements(self):
566 try:
567 mouvements = self.market.ccxt.privatePostReturnOrderTrades({"orderNumber": self.id})
568 except ExchangeError:
569 mouvements = []
570 self.mouvements = []
571
572 for mouvement_hash in mouvements:
573 self.mouvements.append(Mouvement(self.amount.currency,
574 self.base_currency, mouvement_hash))
575
576 def cancel(self):
577 if self.market.debug:
578 self.market.report.log_debug_action("Mark {} as cancelled".format(self))
579 self.status = "canceled"
580 return
581 if self.open and self.id is not None:
582 try:
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)
586 self.fetch()
587
588 class Mouvement:
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))
595 try:
596 self.date = datetime.strptime(hash_.get("date", ""), '%Y-%m-%d %H:%M:%S')
597 except ValueError:
598 self.date = None
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))
603
604 def as_json(self):
605 return {
606 "fee_rate": self.fee_rate,
607 "date": self.date,
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
613 }
614
615 def __repr__(self):
616 if self.fee_rate > 0:
617 fee_rate = " fee: {}%".format(self.fee_rate * 100)
618 else:
619 fee_rate = ""
620 if self.date is None:
621 date = "No date"
622 else:
623 date = self.date
624 return "Mouvement({} ; {} {} ({}){})".format(
625 date, self.action, self.total, self.total_in_base,
626 fee_rate)
627