]> git.immae.eu Git - perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git/blob - portfolio.py
Fix vanishing orders
[perso/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Trader.git] / portfolio.py
1 from datetime import datetime
2 from retry import retry
3 from decimal import Decimal as D, ROUND_DOWN
4 from ccxt import ExchangeError, InsufficientFunds, ExchangeNotAvailable, InvalidOrder, OrderNotCached, OrderNotFound, RequestTimeout, InvalidNonce
5
6 class 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
24 class Amount:
25 def __init__(self, currency, value, linked_to=None, ticker=None, rate=None):
26 self.currency = currency
27 self.value = D(value)
28 self.linked_to = linked_to
29 self.ticker = ticker
30 self.rate = rate
31
32 def in_currency(self, other_currency, market, rate=None, action=None, compute_value="average"):
33 if other_currency == self.currency:
34 return self
35 if rate is not None:
36 return Amount(
37 other_currency,
38 self.value * rate,
39 linked_to=self,
40 rate=rate)
41 asset_ticker = market.get_ticker(self.currency, other_currency)
42 if asset_ticker is not None:
43 rate = Computation.compute_value(asset_ticker, action, compute_value=compute_value)
44 return Amount(
45 other_currency,
46 self.value * rate,
47 linked_to=self,
48 ticker=asset_ticker,
49 rate=rate)
50 else:
51 raise Exception("This asset is not available in the chosen market")
52
53 def as_json(self):
54 return {
55 "currency": self.currency,
56 "value": round(self).value.normalize(),
57 }
58
59 def __round__(self, n=8):
60 return Amount(self.currency, self.value.quantize(D(1)/D(10**n), rounding=ROUND_DOWN))
61
62 def __abs__(self):
63 return Amount(self.currency, abs(self.value))
64
65 def __add__(self, other):
66 if other == 0:
67 return self
68 if other.currency != self.currency and other.value * self.value != 0:
69 raise Exception("Summing amounts must be done with same currencies")
70 return Amount(self.currency, self.value + other.value)
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):
79 if other == 0:
80 return self
81 if other.currency != self.currency and other.value * self.value != 0:
82 raise Exception("Summing amounts must be done with same currencies")
83 return Amount(self.currency, self.value - other.value)
84
85 def __rsub__(self, other):
86 if other == 0:
87 return -self
88 else:
89 return -self.__sub__(other)
90
91 def __mul__(self, value):
92 if not isinstance(value, (int, float, D)):
93 raise TypeError("Amount may only be multiplied by numbers")
94 return Amount(self.currency, self.value * value)
95
96 def __rmul__(self, value):
97 return self.__mul__(value)
98
99 def __floordiv__(self, value):
100 if not isinstance(value, (int, float, D)):
101 raise TypeError("Amount may only be divided by numbers")
102 return Amount(self.currency, self.value / value)
103
104 def __truediv__(self, value):
105 return self.__floordiv__(value)
106
107 def __lt__(self, other):
108 if other == 0:
109 return self.value < 0
110 if self.currency != other.currency:
111 raise Exception("Comparing amounts must be done with same currencies")
112 return self.value < other.value
113
114 def __le__(self, other):
115 return self == other or self < other
116
117 def __gt__(self, other):
118 return not self <= other
119
120 def __ge__(self, other):
121 return not self < other
122
123 def __eq__(self, other):
124 if other == 0:
125 return self.value == 0
126 if self.currency != other.currency:
127 raise Exception("Comparing amounts must be done with same currencies")
128 return self.value == other.value
129
130 def __ne__(self, other):
131 return not self == other
132
133 def __neg__(self):
134 return Amount(self.currency, - self.value)
135
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
148 class Balance:
149 base_keys = ["total", "exchange_total", "exchange_used",
150 "exchange_free", "margin_total", "margin_in_position",
151 "margin_available", "margin_borrowed", "margin_pending_gain"]
152
153 def __init__(self, currency, hash_):
154 self.currency = currency
155 for key in self.base_keys:
156 setattr(self, key, Amount(currency, hash_.get(key, 0)))
157
158 self.margin_position_type = hash_.get("margin_position_type")
159
160 if hash_.get("margin_borrowed_base_currency") is not None:
161 base_currency = hash_["margin_borrowed_base_currency"]
162 for key in [
163 "margin_liquidation_price",
164 "margin_lending_fees",
165 "margin_pending_base_gain",
166 "margin_borrowed_base_price"
167 ]:
168 setattr(self, key, Amount(base_currency, hash_.get(key, 0)))
169
170 def as_json(self):
171 return dict(map(lambda x: (x, getattr(self, x).as_json()["value"]), self.base_keys))
172
173 def __repr__(self):
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:
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))
189 else:
190 margin = " Margin: [❌{}]".format(str(self.margin_in_position))
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]) + ")"
204
205 class Trade:
206 def __init__(self, value_from, value_to, currency, market):
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 = []
213 self.market = market
214 self.closed = False
215 self.inverted = None
216 assert self.value_from.value * self.value_to.value >= 0
217 assert self.value_from.currency == self.value_to.currency
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)
222 self.base_currency = self.value_from.currency
223
224 @property
225 def delta(self):
226 return self.value_to - self.value_from
227
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
235 if abs(self.value_from) < abs(self.value_to):
236 return "acquire"
237 else:
238 return "dispose"
239
240 def order_action(self):
241 if (self.value_from < self.value_to) != self.inverted:
242 return "buy"
243 else:
244 return "sell"
245
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
253 @property
254 def pending(self):
255 return not (self.is_fullfiled or self.closed)
256
257 def close(self):
258 for order in self.orders:
259 order.cancel()
260 self.closed = True
261
262 @property
263 def is_fullfiled(self):
264 return abs(self.filled_amount(in_base_currency=(not self.inverted))) >= abs(self.delta)
265
266 def filled_amount(self, in_base_currency=False):
267 filled_amount = 0
268 for order in self.orders:
269 filled_amount += order.filled_amount(in_base_currency=in_base_currency)
270 return filled_amount
271
272 def update_order(self, order, tick):
273 actions = {
274 0: ["waiting", None],
275 1: ["waiting", None],
276 2: ["adjusting", lambda x, y: (x[y] + x["average"]) / 2],
277 3: ["waiting", None],
278 4: ["waiting", None],
279 5: ["adjusting", lambda x, y: (x[y]*2 + x["average"]) / 3],
280 6: ["waiting", None],
281 7: ["market_fallback", "default"],
282 }
283
284 if tick in actions:
285 update, compute_value = actions[tick]
286 elif tick % 3 == 1:
287 update = "market_adjust"
288 compute_value = "default"
289 else:
290 update = "waiting"
291 compute_value = None
292
293 if compute_value is not None:
294 order.cancel()
295 new_order = self.prepare_order(compute_value=compute_value)
296 else:
297 new_order = None
298
299 self.market.report.log_order(order, tick, update=update,
300 compute_value=compute_value, new_order=new_order)
301
302 if new_order is not None:
303 new_order.run()
304 self.market.report.log_order(order, tick, new_order=new_order)
305
306 def prepare_order(self, close_if_possible=None, compute_value="default"):
307 if self.action is None:
308 return None
309 ticker = self.market.get_ticker(self.currency, self.base_currency)
310 self.inverted = ticker["inverted"]
311 if self.inverted:
312 ticker = ticker["original"]
313 rate = Computation.compute_value(ticker, self.order_action(), compute_value=compute_value)
314
315 # FIXME: Dust amount should be removed from there if they werent
316 # honored in other sales
317 delta_in_base = abs(self.delta)
318 # 9 BTC's worth of move (10 - 1 or 1 - 10 depending on case)
319
320 if not self.inverted:
321 base_currency = self.base_currency
322 # BTC
323 if self.action == "dispose":
324 filled = self.filled_amount(in_base_currency=False)
325 delta = delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
326 # I have 10 BTC worth of FOO, and I want to sell 9 BTC
327 # worth of it, computed first with rate 10 FOO = 1 BTC.
328 # -> I "sell" "90" FOO at proposed rate "rate".
329
330 delta = delta - filled
331 # I already sold 60 FOO, 30 left
332 else:
333 filled = self.filled_amount(in_base_currency=True)
334 delta = (delta_in_base - filled).in_currency(self.currency, self.market, rate=1/rate)
335 # I want to buy 9 BTC worth of FOO, computed with rate
336 # 10 FOO = 1 BTC
337 # -> I "buy" "9 / rate" FOO at proposed rate "rate"
338
339 # I already bought 3 / rate FOO, 6 / rate left
340 else:
341 base_currency = self.currency
342 # FOO
343 if self.action == "dispose":
344 filled = self.filled_amount(in_base_currency=True)
345 # Base is FOO
346
347 delta = (delta_in_base.in_currency(self.currency, self.market, rate=1/self.value_from.rate)
348 - filled).in_currency(self.base_currency, self.market, rate=1/rate)
349 # I have 10 BTC worth of FOO, and I want to sell 9 BTC worth of it
350 # computed at rate 1 Foo = 0.01 BTC
351 # Computation says I should sell it at 125 FOO / BTC
352 # -> delta_in_base = 9 BTC
353 # -> delta = (9 * 1/0.01 FOO) * 1/125 = 7.2 BTC
354 # Action: "buy" "7.2 BTC" at rate "125" "FOO" on market
355
356 # I already bought 300/125 BTC, only 600/125 left
357 else:
358 filled = self.filled_amount(in_base_currency=False)
359 # Base is FOO
360
361 delta = delta_in_base
362 # I have 1 BTC worth of FOO, and I want to buy 9 BTC worth of it
363 # At rate 100 Foo / BTC
364 # Computation says I should buy it at 125 FOO / BTC
365 # -> delta_in_base = 9 BTC
366 # Action: "sell" "9 BTC" at rate "125" "FOO" on market
367
368 delta = delta - filled
369 # I already sold 4 BTC, only 5 left
370
371 if close_if_possible is None:
372 close_if_possible = (self.value_to == 0)
373
374 if delta <= 0:
375 self.market.report.log_error("prepare_order", message="Less to do than already filled: {}".format(delta))
376 return None
377
378 order = Order(self.order_action(),
379 delta, rate, base_currency, self.trade_type,
380 self.market, self, close_if_possible=close_if_possible)
381 self.orders.append(order)
382 return order
383
384 def reopen_same_order(self, order):
385 new_order = Order(order.action, order.amount, order.rate,
386 order.base_currency, order.trade_type, self.market,
387 self, close_if_possible=order.close_if_possible)
388 self.orders.append(new_order)
389 new_order.run()
390 return new_order
391
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
401 def __repr__(self):
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(
410 self.value_from,
411 self.value_to,
412 self.currency,
413 self.action,
414 closed)
415
416 def print_with_order(self, ind=""):
417 self.market.report.print_log("{}{}".format(ind, self))
418 for order in self.orders:
419 self.market.report.print_log("{}\t{}".format(ind, order))
420 for mouvement in order.mouvements:
421 self.market.report.print_log("{}\t\t{}".format(ind, mouvement))
422
423 class RetryException(Exception):
424 pass
425
426 class Order:
427 def __init__(self, action, amount, rate, base_currency, trade_type, market,
428 trade, close_if_possible=False):
429 self.action = action
430 self.amount = amount
431 self.rate = rate
432 self.base_currency = base_currency
433 self.market = market
434 self.trade_type = trade_type
435 self.results = []
436 self.mouvements = []
437 self.status = "pending"
438 self.trade = trade
439 self.close_if_possible = close_if_possible
440 self.id = None
441 self.tries = 0
442 self.start_date = None
443
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
458 def __repr__(self):
459 return "Order({} {} {} at {} {} [{}]{})".format(
460 self.action,
461 self.trade_type,
462 self.amount,
463 self.rate,
464 self.base_currency,
465 self.status,
466 " ✂" if self.close_if_possible else "",
467 )
468
469 @property
470 def account(self):
471 if self.trade_type == "long":
472 return "exchange"
473 else:
474 return "margin"
475
476 @property
477 def open(self):
478 return self.status == "open"
479
480 @property
481 def pending(self):
482 return self.status == "pending"
483
484 @property
485 def finished(self):
486 return self.status.startswith("closed") or self.status == "canceled" or self.status == "error"
487
488 @retry((InsufficientFunds, RetryException, InvalidNonce))
489 def run(self):
490 self.tries += 1
491 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
492 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
493
494 action = "market.ccxt.create_order('{}', 'limit', '{}', {}, price={}, account={})".format(symbol, self.action, amount, self.rate, self.account)
495 if self.market.debug:
496 self.market.report.log_debug_action(action)
497 self.results.append({"debug": True, "id": -1})
498 else:
499 self.start_date = datetime.now()
500 try:
501 self.results.append(self.market.ccxt.create_order(symbol, 'limit', self.action, amount, price=self.rate, account=self.account))
502 except InvalidOrder:
503 # Impossible to honor the order (dust amount)
504 self.status = "closed"
505 self.mark_finished_order()
506 return
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
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")
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
537 except Exception as e:
538 self.status = "error"
539 self.market.report.log_error(action, exception=e)
540 return
541 self.id = self.results[0]["id"]
542 self.status = "open"
543
544 def get_status(self):
545 if self.market.debug:
546 self.market.report.log_debug_action("Getting {} status".format(self))
547 return self.status
548 # other states are "closed" and "canceled"
549 if not self.finished:
550 self.fetch()
551 return self.status
552
553 def fix_disappeared_order(self):
554 if self.status.startswith("closed") and \
555 len(self.mouvements) == 1 and \
556 self.mouvements[0].total_in_base == 0:
557 self.status = "error_disappeared"
558 new_order = self.trade.reopen_same_order(self)
559 self.market.report.log_error("fetch",
560 message="Order {} disappeared, recreating it as {}".format(self, new_order))
561
562 def mark_finished_order(self):
563 if self.status.startswith("closed") and self.market.debug:
564 self.market.report.log_debug_action("Mark {} as finished".format(self))
565 return
566 if self.status.startswith("closed"):
567 if self.trade_type == "short" and self.action == "buy" and self.close_if_possible:
568 self.market.ccxt.close_margin_position(self.amount.currency, self.base_currency)
569
570 def fetch(self):
571 if self.market.debug:
572 self.market.report.log_debug_action("Fetching {}".format(self))
573 return
574 try:
575 result = self.market.ccxt.fetch_order(self.id)
576 self.results.append(result)
577 self.status = result["status"]
578 # Time at which the order started
579 self.timestamp = result["datetime"]
580 except OrderNotCached:
581 self.status = "closed_unknown"
582
583 self.fetch_mouvements()
584
585 self.fix_disappeared_order()
586
587 self.mark_finished_order()
588 # FIXME: consider open order with dust remaining as closed
589
590 def dust_amount_remaining(self):
591 return self.remaining_amount() < Amount(self.amount.currency, D("0.001"))
592
593 def remaining_amount(self):
594 return self.amount - self.filled_amount()
595
596 def filled_amount(self, in_base_currency=False):
597 if self.status == "open":
598 self.fetch()
599 filled_amount = 0
600 for mouvement in self.mouvements:
601 if in_base_currency:
602 filled_amount += mouvement.total_in_base
603 else:
604 filled_amount += mouvement.total
605 return filled_amount
606
607 def fetch_mouvements(self):
608 try:
609 mouvements = self.market.ccxt.privatePostReturnOrderTrades({"orderNumber": self.id})
610 except ExchangeError:
611 mouvements = []
612 self.mouvements = []
613
614 for mouvement_hash in mouvements:
615 self.mouvements.append(Mouvement(self.amount.currency,
616 self.base_currency, mouvement_hash))
617
618 def cancel(self):
619 if self.market.debug:
620 self.market.report.log_debug_action("Mark {} as cancelled".format(self))
621 self.status = "canceled"
622 return
623 if self.open and self.id is not None:
624 try:
625 self.market.ccxt.cancel_order(self.id)
626 except OrderNotFound as e: # Closed inbetween
627 self.market.report.log_error("cancel_order", message="Already cancelled order", exception=e)
628 self.fetch()
629
630 def retrieve_order(self):
631 symbol = "{}/{}".format(self.amount.currency, self.base_currency)
632 amount = round(self.amount, self.market.ccxt.order_precision(symbol)).value
633 start_timestamp = self.start_date.timestamp() - 5
634
635 similar_open_orders = self.market.ccxt.fetch_orders(symbol=symbol, since=start_timestamp)
636 for order in similar_open_orders:
637 if (order["info"]["margin"] == 1 and self.account == "exchange") or\
638 (order["info"]["margin"] != 1 and self.account == "margin"):
639 i_m_tested = True # coverage bug ?!
640 continue
641 if order["info"]["side"] != self.action:
642 continue
643 amount_diff = round(
644 abs(D(order["info"]["startingAmount"]) - amount),
645 self.market.ccxt.order_precision(symbol))
646 rate_diff = round(
647 abs(D(order["info"]["rate"]) - self.rate),
648 self.market.ccxt.order_precision(symbol))
649 if amount_diff != 0 or rate_diff != 0:
650 continue
651 self.results.append({"id": order["id"]})
652 return True
653
654 similar_trades = self.market.ccxt.fetch_my_trades(symbol=symbol, since=start_timestamp)
655 # FIXME: use set instead of sorted(list(...))
656 for order_id in sorted(list(map(lambda x: x["order"], similar_trades))):
657 trades = list(filter(lambda x: x["order"] == order_id, similar_trades))
658 if any(x["timestamp"] < start_timestamp for x in trades):
659 continue
660 if any(x["side"] != self.action for x in trades):
661 continue
662 if any(x["info"]["category"] == "exchange" and self.account == "margin" for x in trades) or\
663 any(x["info"]["category"] == "marginTrade" and self.account == "exchange" for x in trades):
664 continue
665 trade_sum = sum(D(x["info"]["amount"]) for x in trades)
666 amount_diff = round(abs(trade_sum - amount),
667 self.market.ccxt.order_precision(symbol))
668 if amount_diff != 0:
669 continue
670 if (self.action == "sell" and any(D(x["info"]["rate"]) < self.rate for x in trades)) or\
671 (self.action == "buy" and any(D(x["info"]["rate"]) > self.rate for x in trades)):
672 continue
673 self.results.append({"id": order_id})
674 return True
675
676 return False
677
678 class Mouvement:
679 def __init__(self, currency, base_currency, hash_):
680 self.currency = currency
681 self.base_currency = base_currency
682 self.id = hash_.get("tradeID")
683 self.action = hash_.get("type")
684 self.fee_rate = D(hash_.get("fee", -1))
685 try:
686 self.date = datetime.strptime(hash_.get("date", ""), '%Y-%m-%d %H:%M:%S')
687 except ValueError:
688 self.date = None
689 self.rate = D(hash_.get("rate", 0))
690 self.total = Amount(currency, hash_.get("amount", 0))
691 # rate * total = total_in_base
692 self.total_in_base = Amount(base_currency, hash_.get("total", 0))
693
694 def as_json(self):
695 return {
696 "fee_rate": self.fee_rate,
697 "date": self.date,
698 "action": self.action,
699 "total": self.total.value,
700 "currency": self.currency,
701 "total_in_base": self.total_in_base.value,
702 "base_currency": self.base_currency
703 }
704
705 def __repr__(self):
706 if self.fee_rate > 0:
707 fee_rate = " fee: {}%".format(self.fee_rate * 100)
708 else:
709 fee_rate = ""
710 if self.date is None:
711 date = "No date"
712 else:
713 date = self.date
714 return "Mouvement({} ; {} {} ({}){})".format(
715 date, self.action, self.total, self.total_in_base,
716 fee_rate)
717