2 from datetime
import datetime
, timedelta
3 from decimal
import Decimal
as D
, ROUND_DOWN
4 from json
import JSONDecodeError
5 from simplejson
.errors
import JSONDecodeError
as SimpleJSONDecodeError
6 from ccxt
import ExchangeError
, ExchangeNotAvailable
, InvalidOrder
9 # FIXME: correctly handle web call timeouts
12 URL
= "https://cryptoportfolio.io/wp-content/uploads/portfolio/json/cryptoportfolio.json"
18 def wait_for_recent(cls
, market
, delta
=4):
19 cls
.repartition(market
, refetch
=True)
20 while cls
.last_date
is None or datetime
.now() - cls
.last_date
> timedelta(delta
):
22 market
.report
.print_log("Attempt to fetch up-to-date cryptoportfolio")
23 cls
.repartition(market
, refetch
=True)
26 def repartition(cls
, market
, liquidity
="medium", refetch
=False):
27 cls
.parse_cryptoportfolio(market
, refetch
=refetch
)
28 liquidities
= cls
.liquidities
[liquidity
]
29 return liquidities
[cls
.last_date
]
32 def get_cryptoportfolio(cls
, market
):
34 r
= requests
.get(cls
.URL
)
35 market
.report
.log_http_request(r
.request
.method
,
36 r
.request
.url
, r
.request
.body
, r
.request
.headers
, r
)
37 except Exception as e
:
38 market
.report
.log_error("get_cryptoportfolio", exception
=e
)
41 cls
.data
= r
.json(parse_int
=D
, parse_float
=D
)
42 except (JSONDecodeError
, SimpleJSONDecodeError
):
46 def parse_cryptoportfolio(cls
, market
, refetch
=False):
47 if refetch
or cls
.data
is None:
48 cls
.get_cryptoportfolio(market
)
50 def filter_weights(weight_hash
):
51 if weight_hash
[1][0] == 0:
53 if weight_hash
[0] == "_row":
58 def clean_weights_(h
):
59 if h
[0].endswith("s"):
60 return [h
[0][0:-1], (h
[1][i
], "short")]
62 return [h
[0], (h
[1][i
], "long")]
65 def parse_weights(portfolio_hash
):
66 weights_hash
= portfolio_hash
["weights"]
68 for i
in range(len(weights_hash
["_row"])):
69 date
= datetime
.strptime(weights_hash
["_row"][i
], "%Y-%m-%d")
70 weights
[date
] = dict(filter(
72 map(clean_weights(i
), weights_hash
.items())))
75 high_liquidity
= parse_weights(cls
.data
["portfolio_1"])
76 medium_liquidity
= parse_weights(cls
.data
["portfolio_2"])
79 "medium": medium_liquidity
,
80 "high": high_liquidity
,
82 cls
.last_date
= max(max(medium_liquidity
.keys()), max(high_liquidity
.keys()))
86 "default": lambda x
, y
: x
[y
],
87 "average": lambda x
, y
: x
["average"],
88 "bid": lambda x
, y
: x
["bid"],
89 "ask": lambda x
, y
: x
["ask"],
93 def compute_value(cls
, ticker
, action
, compute_value
="default"):
98 if isinstance(compute_value
, str):
99 compute_value
= cls
.computations
[compute_value
]
100 return compute_value(ticker
, action
)
103 def __init__(self
, currency
, value
, linked_to
=None, ticker
=None, rate
=None):
104 self
.currency
= currency
105 self
.value
= D(value
)
106 self
.linked_to
= linked_to
110 def in_currency(self
, other_currency
, market
, rate
=None, action
=None, compute_value
="average"):
111 if other_currency
== self
.currency
:
119 asset_ticker
= market
.get_ticker(self
.currency
, other_currency
)
120 if asset_ticker
is not None:
121 rate
= Computation
.compute_value(asset_ticker
, action
, compute_value
=compute_value
)
129 raise Exception("This asset is not available in the chosen market")
133 "currency": self
.currency
,
134 "value": round(self
).value
.normalize(),
137 def __round__(self
, n
=8):
138 return Amount(self
.currency
, self
.value
.quantize(D(1)/D(10**n
), rounding
=ROUND_DOWN
))
141 return Amount(self
.currency
, abs(self
.value
))
143 def __add__(self
, other
):
146 if other
.currency
!= self
.currency
and other
.value
* self
.value
!= 0:
147 raise Exception("Summing amounts must be done with same currencies")
148 return Amount(self
.currency
, self
.value
+ other
.value
)
150 def __radd__(self
, other
):
154 return self
.__add
__(other
)
156 def __sub__(self
, other
):
159 if other
.currency
!= self
.currency
and other
.value
* self
.value
!= 0:
160 raise Exception("Summing amounts must be done with same currencies")
161 return Amount(self
.currency
, self
.value
- other
.value
)
163 def __rsub__(self
, other
):
167 return -self
.__sub
__(other
)
169 def __mul__(self
, value
):
170 if not isinstance(value
, (int, float, D
)):
171 raise TypeError("Amount may only be multiplied by numbers")
172 return Amount(self
.currency
, self
.value
* value
)
174 def __rmul__(self
, value
):
175 return self
.__mul
__(value
)
177 def __floordiv__(self
, value
):
178 if not isinstance(value
, (int, float, D
)):
179 raise TypeError("Amount may only be divided by numbers")
180 return Amount(self
.currency
, self
.value
/ value
)
182 def __truediv__(self
, value
):
183 return self
.__floordiv
__(value
)
185 def __lt__(self
, other
):
187 return self
.value
< 0
188 if self
.currency
!= other
.currency
:
189 raise Exception("Comparing amounts must be done with same currencies")
190 return self
.value
< other
.value
192 def __le__(self
, other
):
193 return self
== other
or self
< other
195 def __gt__(self
, other
):
196 return not self
<= other
198 def __ge__(self
, other
):
199 return not self
< other
201 def __eq__(self
, other
):
203 return self
.value
== 0
204 if self
.currency
!= other
.currency
:
205 raise Exception("Comparing amounts must be done with same currencies")
206 return self
.value
== other
.value
208 def __ne__(self
, other
):
209 return not self
== other
212 return Amount(self
.currency
, - self
.value
)
215 if self
.linked_to
is None:
216 return "{:.8f} {}".format(self
.value
, self
.currency
)
218 return "{:.8f} {} [{}]".format(self
.value
, self
.currency
, self
.linked_to
)
221 if self
.linked_to
is None:
222 return "Amount({:.8f} {})".format(self
.value
, self
.currency
)
224 return "Amount({:.8f} {} -> {})".format(self
.value
, self
.currency
, repr(self
.linked_to
))
227 base_keys
= ["total", "exchange_total", "exchange_used",
228 "exchange_free", "margin_total", "margin_borrowed",
231 def __init__(self
, currency
, hash_
):
232 self
.currency
= currency
233 for key
in self
.base_keys
:
234 setattr(self
, key
, Amount(currency
, hash_
.get(key
, 0)))
236 self
.margin_position_type
= hash_
.get("margin_position_type")
238 if hash_
.get("margin_borrowed_base_currency") is not None:
239 base_currency
= hash_
["margin_borrowed_base_currency"]
241 "margin_liquidation_price",
242 "margin_pending_gain",
243 "margin_lending_fees",
244 "margin_borrowed_base_price"
246 setattr(self
, key
, Amount(base_currency
, hash_
.get(key
, 0)))
249 return dict(map(lambda x
: (x
, getattr(self
, x
).as_json()["value"]), self
.base_keys
))
252 if self
.exchange_total
> 0:
253 if self
.exchange_free
> 0 and self
.exchange_used
> 0:
254 exchange
= " Exch: [✔{} + ❌{} = {}]".format(str(self
.exchange_free
), str(self
.exchange_used
), str(self
.exchange_total
))
255 elif self
.exchange_free
> 0:
256 exchange
= " Exch: [✔{}]".format(str(self
.exchange_free
))
258 exchange
= " Exch: [❌{}]".format(str(self
.exchange_used
))
262 if self
.margin_total
> 0:
263 if self
.margin_free
!= 0 and self
.margin_borrowed
!= 0:
264 margin
= " Margin: [✔{} + borrowed {} = {}]".format(str(self
.margin_free
), str(self
.margin_borrowed
), str(self
.margin_total
))
265 elif self
.margin_free
!= 0:
266 margin
= " Margin: [✔{}]".format(str(self
.margin_free
))
268 margin
= " Margin: [borrowed {}]".format(str(self
.margin_borrowed
))
269 elif self
.margin_total
< 0:
270 margin
= " Margin: [{} @@ {}/{}]".format(str(self
.margin_total
),
271 str(self
.margin_borrowed_base_price
),
272 str(self
.margin_lending_fees
))
276 if self
.margin_total
!= 0 and self
.exchange_total
!= 0:
277 total
= " Total: [{}]".format(str(self
.total
))
281 return "Balance({}".format(self
.currency
) + "".join([exchange
, margin
, total
]) + ")"
284 def __init__(self
, value_from
, value_to
, currency
, market
):
285 # We have value_from of currency, and want to finish with value_to of
286 # that currency. value_* may not be in currency's terms
287 self
.currency
= currency
288 self
.value_from
= value_from
289 self
.value_to
= value_to
292 assert self
.value_from
.currency
== self
.value_to
.currency
293 if self
.value_from
!= 0:
294 assert self
.value_from
.linked_to
is not None and self
.value_from
.linked_to
.currency
== self
.currency
295 elif self
.value_from
.linked_to
is None:
296 self
.value_from
.linked_to
= Amount(self
.currency
, 0)
297 self
.base_currency
= self
.value_from
.currency
301 if self
.value_from
== self
.value_to
:
303 if self
.base_currency
== self
.currency
:
306 if abs(self
.value_from
) < abs(self
.value_to
):
311 def order_action(self
, inverted
):
312 if (self
.value_from
< self
.value_to
) != inverted
:
318 def trade_type(self
):
319 if self
.value_from
+ self
.value_to
< 0:
324 def filled_amount(self
, in_base_currency
=False):
326 for order
in self
.orders
:
327 filled_amount
+= order
.filled_amount(in_base_currency
=in_base_currency
)
330 def update_order(self
, order
, tick
):
332 if tick
in [0, 1, 3, 4, 6]:
337 compute_value
= 'lambda x, y: (x[y] + x["average"]) / 2'
338 new_order
= self
.prepare_order(compute_value
=lambda x
, y
: (x
[y
] + x
["average"]) / 2)
341 compute_value
= 'lambda x, y: (x[y]*2 + x["average"]) / 3'
342 new_order
= self
.prepare_order(compute_value
=lambda x
, y
: (x
[y
]*2 + x
["average"]) / 3)
344 if (tick
- 7) % 3 == 0:
345 new_order
= self
.prepare_order(compute_value
="default")
346 update
= "market_adjust"
347 compute_value
= "default"
352 update
= "market_fallback"
354 self
.market
.report
.log_order(order
, tick
, update
=update
,
355 compute_value
=compute_value
, new_order
=new_order
)
357 if new_order
is not None:
360 self
.market
.report
.log_order(order
, tick
, new_order
=new_order
)
362 def prepare_order(self
, compute_value
="default"):
363 if self
.action
is None:
365 ticker
= self
.market
.get_ticker(self
.currency
, self
.base_currency
)
366 inverted
= ticker
["inverted"]
368 ticker
= ticker
["original"]
369 rate
= Computation
.compute_value(ticker
, self
.order_action(inverted
), compute_value
=compute_value
)
371 #TODO: store when the order is considered filled
372 # FIXME: Dust amount should be removed from there if they werent
373 # honored in other sales
374 delta_in_base
= abs(self
.value_from
- self
.value_to
)
375 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
378 base_currency
= self
.base_currency
380 if self
.action
== "dispose":
381 filled
= self
.filled_amount(in_base_currency
=False)
382 delta
= delta_in_base
.in_currency(self
.currency
, self
.market
, rate
=1/self
.value_from
.rate
)
383 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
384 # worth of it, computed first with rate 10 FOO = 1 BTC.
385 # -> I "sell" "90" FOO at proposed rate "rate".
387 delta
= delta
- filled
388 # I already sold 60 FOO, 30 left
390 filled
= self
.filled_amount(in_base_currency
=True)
391 delta
= (delta_in_base
- filled
).in_currency(self
.currency
, self
.market
, rate
=1/rate
)
392 # I want to buy 9 BTC worth of FOO, computed with rate
394 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
396 # I already bought 3 / rate FOO, 6 / rate left
398 base_currency
= self
.currency
400 if self
.action
== "dispose":
401 filled
= self
.filled_amount(in_base_currency
=True)
404 delta
= (delta_in_base
.in_currency(self
.currency
, self
.market
, rate
=1/self
.value_from
.rate
)
405 - filled
).in_currency(self
.base_currency
, self
.market
, rate
=1/rate
)
406 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
407 # computed at rate 1 Foo = 0.01 BTC
408 # Computation says I should sell it at 125 FOO / BTC
409 # -> delta_in_base = 9 BTC
410 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
411 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
413 # I already bought 300/125 BTC, only 600/125 left
415 filled
= self
.filled_amount(in_base_currency
=False)
418 delta
= delta_in_base
419 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
420 # At rate 100 Foo / BTC
421 # Computation says I should buy it at 125 FOO / BTC
422 # -> delta_in_base = 9 BTC
423 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
425 delta
= delta
- filled
426 # I already sold 4 BTC, only 5 left
428 close_if_possible
= (self
.value_to
== 0)
431 self
.market
.report
.log_error("prepare_order", message
="Less to do than already filled: {}".format(delta
))
434 order
= Order(self
.order_action(inverted
),
435 delta
, rate
, base_currency
, self
.trade_type
,
436 self
.market
, self
, close_if_possible
=close_if_possible
)
437 self
.orders
.append(order
)
442 "action": self
.action
,
443 "from": self
.value_from
.as_json()["value"],
444 "to": self
.value_to
.as_json()["value"],
445 "currency": self
.currency
,
446 "base_currency": self
.base_currency
,
450 return "Trade({} -> {} in {}, {})".format(
456 def print_with_order(self
, ind
=""):
457 self
.market
.report
.print_log("{}{}".format(ind
, self
))
458 for order
in self
.orders
:
459 self
.market
.report
.print_log("{}\t{}".format(ind
, order
))
460 for mouvement
in order
.mouvements
:
461 self
.market
.report
.print_log("{}\t\t{}".format(ind
, mouvement
))
464 def __init__(self
, action
, amount
, rate
, base_currency
, trade_type
, market
,
465 trade
, close_if_possible
=False):
469 self
.base_currency
= base_currency
471 self
.trade_type
= trade_type
474 self
.status
= "pending"
476 self
.close_if_possible
= close_if_possible
478 self
.fetch_cache_timestamp
= None
482 "action": self
.action
,
483 "trade_type": self
.trade_type
,
484 "amount": self
.amount
.as_json()["value"],
485 "currency": self
.amount
.as_json()["currency"],
486 "base_currency": self
.base_currency
,
488 "status": self
.status
,
489 "close_if_possible": self
.close_if_possible
,
491 "mouvements": list(map(lambda x
: x
.as_json(), self
.mouvements
))
495 return "Order({} {} {} at {} {} [{}]{})".format(
502 " ✂" if self
.close_if_possible
else "",
507 if self
.trade_type
== "long":
514 return self
.status
== "open"
518 return self
.status
== "pending"
522 return self
.status
== "closed" or self
.status
== "canceled" or self
.status
== "error"
525 symbol
= "{}/{}".format(self
.amount
.currency
, self
.base_currency
)
526 amount
= round(self
.amount
, self
.market
.ccxt
.order_precision(symbol
)).value
528 if self
.market
.debug
:
529 self
.market
.report
.log_debug_action("market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(
530 symbol
, self
.action
, amount
, self
.rate
, self
.account
))
531 self
.results
.append({"debug": True, "id": -1}
)
534 self
.results
.append(self
.market
.ccxt
.create_order(symbol
, 'limit', self
.action
, amount
, price
=self
.rate
, account
=self
.account
))
535 except (ExchangeNotAvailable
, InvalidOrder
):
536 # Impossible to honor the order (dust amount)
537 self
.status
= "closed"
538 self
.mark_finished_order()
540 except Exception as e
:
541 self
.status
= "error"
542 action
= "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol
, self
.action
, amount
, self
.rate
, self
.account
)
543 self
.market
.report
.log_error(action
, exception
=e
)
545 self
.id = self
.results
[0]["id"]
548 def get_status(self
):
549 if self
.market
.debug
:
550 self
.market
.report
.log_debug_action("Getting {} status".format(self
))
552 # other states are "closed" and "canceled"
553 if not self
.finished
:
556 self
.mark_finished_order()
559 def mark_finished_order(self
):
560 if self
.market
.debug
:
561 self
.market
.report
.log_debug_action("Mark {} as finished".format(self
))
563 if self
.status
== "closed":
564 if self
.trade_type
== "short" and self
.action
== "buy" and self
.close_if_possible
:
565 self
.market
.ccxt
.close_margin_position(self
.amount
.currency
, self
.base_currency
)
567 def fetch(self
, force
=False):
568 if self
.market
.debug
:
569 self
.market
.report
.log_debug_action("Fetching {}".format(self
))
571 if (not force
and self
.fetch_cache_timestamp
is not None
572 and time
.time() - self
.fetch_cache_timestamp
< 10):
574 self
.fetch_cache_timestamp
= time
.time()
576 result
= self
.market
.ccxt
.fetch_order(self
.id)
577 self
.results
.append(result
)
579 self
.status
= result
["status"]
580 # Time at which the order started
581 self
.timestamp
= result
["datetime"]
582 self
.fetch_mouvements()
584 # FIXME: consider open order with dust remaining as closed
586 def dust_amount_remaining(self
):
587 return self
.remaining_amount() < Amount(self
.amount
.currency
, D("0.001"))
589 def remaining_amount(self
):
590 if self
.status
== "open":
592 return self
.amount
- self
.filled_amount()
594 def filled_amount(self
, in_base_currency
=False):
595 if self
.status
== "open":
598 for mouvement
in self
.mouvements
:
600 filled_amount
+= mouvement
.total_in_base
602 filled_amount
+= mouvement
.total
605 def fetch_mouvements(self
):
607 mouvements
= self
.market
.ccxt
.privatePostReturnOrderTrades({"orderNumber": self.id}
)
608 except ExchangeError
:
612 for mouvement_hash
in mouvements
:
613 self
.mouvements
.append(Mouvement(self
.amount
.currency
,
614 self
.base_currency
, mouvement_hash
))
617 if self
.market
.debug
:
618 self
.market
.report
.log_debug_action("Mark {} as cancelled".format(self
))
619 self
.status
= "canceled"
621 self
.market
.ccxt
.cancel_order(self
.id)
625 def __init__(self
, currency
, base_currency
, hash_
):
626 self
.currency
= currency
627 self
.base_currency
= base_currency
628 self
.id = hash_
.get("tradeID")
629 self
.action
= hash_
.get("type")
630 self
.fee_rate
= D(hash_
.get("fee", -1))
632 self
.date
= datetime
.strptime(hash_
.get("date", ""), '%Y-%m-%d %H:%M:%S')
635 self
.rate
= D(hash_
.get("rate", 0))
636 self
.total
= Amount(currency
, hash_
.get("amount", 0))
637 # rate * total = total_in_base
638 self
.total_in_base
= Amount(base_currency
, hash_
.get("total", 0))
642 "fee_rate": self
.fee_rate
,
644 "action": self
.action
,
645 "total": self
.total
.value
,
646 "currency": self
.currency
,
647 "total_in_base": self
.total_in_base
.value
,
648 "base_currency": self
.base_currency
652 if self
.fee_rate
> 0:
653 fee_rate
= " fee: {}%".format(self
.fee_rate
* 100)
656 if self
.date
is None:
660 return "Mouvement({} ; {} {} ({}){})".format(
661 date
, self
.action
, self
.total
, self
.total_in_base
,