]> git.immae.eu Git - perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git/blob - portfolio.py
Move Portfolio to store and cleanup methods
[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 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
224
225 @property
226 def delta(self):
227 return self.value_to - self.value_from
228
229 @property
230 def action(self):
231 if self.value_from == self.value_to:
232 return None
233 if self.base_currency == self.currency:
234 return None
235
236 if abs(self.value_from) < abs(self.value_to):
237 return "acquire"
238 else:
239 return "dispose"
240
241 def order_action(self, inverted):
242 if (self.value_from < self.value_to) != inverted:
243 return "buy"
244 else:
245 return "sell"
246
247 @property
248 def trade_type(self):
249 if self.value_from + self.value_to < 0:
250 return "short"
251 else:
252 return "long"
253
254 @property
255 def pending(self):
256 return not (self.is_fullfiled or self.closed)
257
258 def close(self):
259 for order in self.orders:
260 order.cancel()
261 self.closed = True
262
263 @property
264 def is_fullfiled(self):
265 return abs(self.filled_amount(in_base_currency=True)) >= abs(self.delta)
266
267 def filled_amount(self, in_base_currency=False):
268 filled_amount = 0
269 for order in self.orders:
270 filled_amount += order.filled_amount(in_base_currency=in_base_currency)
271 return filled_amount
272
273 def update_order(self, order, tick):
274 actions = {
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"],
283 }
284
285 if tick in actions:
286 update, compute_value = actions[tick]
287 elif tick % 3 == 1:
288 update = "market_adjust"
289 compute_value = "default"
290 else:
291 update = "waiting"
292 compute_value = None
293
294 if compute_value is not None:
295 order.cancel()
296 new_order = self.prepare_order(compute_value=compute_value)
297 else:
298 new_order = None
299
300 self.market.report.log_order(order, tick, update=update,
301 compute_value=compute_value, new_order=new_order)
302
303 if new_order is not None:
304 new_order.run()
305 self.market.report.log_order(order, tick, new_order=new_order)
306
307 def prepare_order(self, close_if_possible=None, compute_value="default"):
308 if self.action is None:
309 return None
310 ticker = self.market.get_ticker(self.currency, self.base_currency)
311 inverted = ticker["inverted"]
312 if inverted:
313 ticker = ticker["original"]
314 rate = Computation.compute_value(ticker, self.order_action(inverted), compute_value=compute_value)
315
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)
320
321 if not inverted:
322 base_currency = self.base_currency
323 # BTC
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".
330
331 delta = delta - filled
332 # I already sold 60 FOO, 30 left
333 else:
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
337 # 10 FOO = 1 BTC
338 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
339
340 # I already bought 3 / rate FOO, 6 / rate left
341 else:
342 base_currency = self.currency
343 # FOO
344 if self.action == "dispose":
345 filled = self.filled_amount(in_base_currency=True)
346 # Base is FOO
347
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
356
357 # I already bought 300/125 BTC, only 600/125 left
358 else:
359 filled = self.filled_amount(in_base_currency=False)
360 # Base is FOO
361
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
368
369 delta = delta - filled
370 # I already sold 4 BTC, only 5 left
371
372 if close_if_possible is None:
373 close_if_possible = (self.value_to == 0)
374
375 if delta <= 0:
376 self.market.report.log_error("prepare_order", message="Less to do than already filled: {}".format(delta))
377 return None
378
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)
383 return order
384
385 def as_json(self):
386 return {
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,
392 }
393
394 def __repr__(self):
395 if self.closed and not self.is_fullfiled:
396 closed = " ❌"
397 elif self.is_fullfiled:
398 closed = " ✔"
399 else:
400 closed = ""
401
402 return "Trade({} -> {} in {}, {}{})".format(
403 self.value_from,
404 self.value_to,
405 self.currency,
406 self.action,
407 closed)
408
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))
415
416 class Order:
417 def __init__(self, action, amount, rate, base_currency, trade_type, market,
418 trade, close_if_possible=False):
419 self.action = action
420 self.amount = amount
421 self.rate = rate
422 self.base_currency = base_currency
423 self.market = market
424 self.trade_type = trade_type
425 self.results = []
426 self.mouvements = []
427 self.status = "pending"
428 self.trade = trade
429 self.close_if_possible = close_if_possible
430 self.id = None
431 self.tries = 0
432
433 def as_json(self):
434 return {
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,
440 "rate": self.rate,
441 "status": self.status,
442 "close_if_possible": self.close_if_possible,
443 "id": self.id,
444 "mouvements": list(map(lambda x: x.as_json(), self.mouvements))
445 }
446
447 def __repr__(self):
448 return "Order({} {} {} at {} {} [{}]{})".format(
449 self.action,
450 self.trade_type,
451 self.amount,
452 self.rate,
453 self.base_currency,
454 self.status,
455 " ✂" if self.close_if_possible else "",
456 )
457
458 @property
459 def account(self):
460 if self.trade_type == "long":
461 return "exchange"
462 else:
463 return "margin"
464
465 @property
466 def open(self):
467 return self.status == "open"
468
469 @property
470 def pending(self):
471 return self.status == "pending"
472
473 @property
474 def finished(self):
475 return self.status == "closed" or self.status == "canceled" or self.status == "error"
476
477 @retry(InsufficientFunds)
478 def run(self):
479 self.tries += 1
480 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
481 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
482
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})
487 else:
488 action = "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol, self.action, amount, self.rate, self.account)
489 try:
490 self.results.append(self.market.ccxt.create_order(symbol, 'limit', self.action, amount, price=self.rate, account=self.account))
491 except InvalidOrder:
492 # Impossible to honor the order (dust amount)
493 self.status = "closed"
494 self.mark_finished_order()
495 return
496 except InsufficientFunds as e:
497 if self.tries < 5:
498 self.market.report.log_error(action, message="Retrying with reduced amount", exception=e)
499 self.amount = self.amount * D("0.99")
500 raise e
501 else:
502 self.market.report.log_error(action, message="Giving up {}".format(self), exception=e)
503 self.status = "error"
504 return
505 except Exception as e:
506 self.status = "error"
507 self.market.report.log_error(action, exception=e)
508 return
509 self.id = self.results[0]["id"]
510 self.status = "open"
511
512 def get_status(self):
513 if self.market.debug:
514 self.market.report.log_debug_action("Getting {} status".format(self))
515 return self.status
516 # other states are "closed" and "canceled"
517 if not self.finished:
518 self.fetch()
519 if self.finished:
520 self.mark_finished_order()
521 return self.status
522
523 def mark_finished_order(self):
524 if self.market.debug:
525 self.market.report.log_debug_action("Mark {} as finished".format(self))
526 return
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)
530
531 def fetch(self):
532 if self.market.debug:
533 self.market.report.log_debug_action("Fetching {}".format(self))
534 return
535 try:
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"
543
544 self.fetch_mouvements()
545
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