brndnmtthws/optimal-buy-cbpro

View on GitHub
optimal_buy_cbpro/optimal_buy_cbpro.py

Summary

Maintainability
D
1 day
Test Coverage
F
30%
#!/usr/bin/env python3

import cbpro
import argparse
import sys
import math
import time
import dateutil.parser
import json
import requests
from .history import Order, Deposit, Withdrawal, get_session
from requests.exceptions import HTTPError


def get_weights(coins, fiat_currency):
    market_cap = {}
    try:
        response = requests.get("https://api.coincap.io/v2/assets")
        assets = response.json()
        coin_data = {}
        for coin in assets["data"]:
            coin_data[coin["symbol"]] = coin
        for c in coins:
            market_cap[c] = float(coin_data[c]["marketCapUsd"])
    except HTTPError as e:
        print(
            "caught exception when fetching ticker for {} with name={}".format(
                c, coins[c]["name"]
            )
        )
        raise e

    total_market_cap = sum(market_cap.values())

    weights = {}
    for c in coins:
        weights[c] = market_cap[c] / total_market_cap
    print("coin weights:")
    for w in weights:
        print("  {0}: {1:.4f}".format(w, weights[w]))
    print()
    return weights


def deposit(args, cbpro_client, db_session):
    if args.amount is None:
        print("please specify deposit amount with `--amount`")
        sys.exit(1)
    if args.payment_method_id is None:
        print("please provide a bank ID with `--payment-method-id`")
        sys.exit(1)
    print("performing deposit, amount={} {}".format(args.amount, args.fiat_currency))
    deposit = cbpro_client.deposit(
        payment_method_id=args.payment_method_id,
        amount=args.amount,
        currency=args.fiat_currency,
    )
    print("deposit={}".format(deposit))
    if "id" in deposit:
        db_session.add(
            Deposit(
                payment_method_id=args.payment_method_id,
                amount=args.amount,
                currency=args.fiat_currency,
                payout_at=dateutil.parser.parse(deposit["payout_at"]),
                cbpro_deposit_id=deposit["id"],
            )
        )
        db_session.commit()


def get_products(cbpro_client, coins, fiat_currency):
    products = cbpro_client.get_products()
    for p in products:
        if p["base_currency"] in coins and p["quote_currency"] == fiat_currency:
            coins[p["base_currency"]]["minimum_order_size"] = float(p["min_market_funds"])
    return products


def get_prices(cbpro_client, coins, fiat_currency):
    prices = {}
    for c in coins:
        ticker = cbpro_client.get_product_ticker(
            product_id="{}-{}".format(c, fiat_currency)
        )
        if "price" not in ticker:
            raise (Exception("no price available for {} ticker={}".format(c, ticker)))
        print("{} ticker={}".format(c, ticker))
        prices[c] = float(ticker["price"])
    return prices


def get_external_balance(coins, coin):
    external_balance = float(coins[coin].get("external_balance", 0))
    if external_balance > 0:
        print("including external balance of {} {}".format(external_balance, coin))
    return external_balance


def get_fiat_balances(args, coins, accounts, withdrawn_balances, prices):
    balances = {}
    for a in accounts:
        if a["currency"] == args.fiat_currency:
            balances[args.fiat_currency] = float(a["balance"])
        elif a["currency"] in coins:
            balance = float(a["balance"]) + get_external_balance(coins, a["currency"])
            if a["currency"] in withdrawn_balances:
                balance = balance + withdrawn_balances[a["currency"]]
            balances[a["currency"]] = balance * prices[a["currency"]]
    for c in coins:
        if c not in balances:
            balances[c] = 0
    return balances


def get_account(accounts, currency):
    for a in accounts:
        if a["currency"] == currency:
            return a


def set_buy_order(args, coin, price, size, cbpro_client, db_session):
    print("placing order coin={0} price={1:.2f} size={2:.8f}".format(coin, price, size))
    order = cbpro_client.buy(
        price="{0:.2f}".format(price),
        size="{0:.8f}".format(size),
        order_type="limit",
        product_id="{}-{}".format(coin, args.fiat_currency),
        post_only="true",
    )
    print("order={}".format(order))
    if "id" in order:
        db_session.add(
            Order(
                currency=coin,
                size=size,
                price=price,
                cbpro_order_id=order["id"],
                created_at=dateutil.parser.parse(order["created_at"]),
            )
        )
        db_session.commit()
    return order


def generate_buy_orders(coins, coin, args, amount_to_buy, price):
    from decimal import Decimal, getcontext, ROUND_DOWN

    getcontext().prec = 8
    getcontext().rounding = ROUND_DOWN
    buy_orders = []

    # If the size is <= minimum * 5, set a single buy order, because otherwise
    # it will get rejected
    minimum_order_size = coins[coin].get("minimum_order_size", 0.01)
    number_of_orders = min(
        [
            args.order_count,
            max([1, math.floor(amount_to_buy / (minimum_order_size * price))]),
        ]
    )

    # Set 5 buy orders
    amount = Decimal(math.floor(100 * amount_to_buy / number_of_orders)) / Decimal(100)
    discount = 1 - args.starting_discount

    for _ in range(0, number_of_orders):
        discounted_price = Decimal(math.floor(100.0 * price * discount)) / Decimal(100)
        size = amount / discounted_price

        buy_orders.append({"price": float(discounted_price), "size": float(size)})
        discount = discount - args.discount_step
    return buy_orders


def place_buy_orders(args, amount_to_buy, coins, coin, price, cbpro_client, db_session):
    if amount_to_buy <= 0.01:
        print(
            "{}: balance_difference_fiat={}, not buying {}".format(
                coin, amount_to_buy, coin
            )
        )
        return
    if price <= 0:
        print("price={}, not buying {}".format(price, coin))
        return

    buy_orders = generate_buy_orders(coins, coin, args, amount_to_buy, price)
    for order in buy_orders:
        set_buy_order(
            args, coin, order["price"], order["size"], cbpro_client, db_session
        )


def start_buy_orders(
    args, coins, accounts, prices, fiat_balances, fiat_amount, cbpro_client, db_session
):
    weights = get_weights(coins, args.fiat_currency)

    # Determine amount of each coin, in fiat, to buy
    fiat_balance_sum = sum(fiat_balances.values())
    print("fiat_balance_sum={}".format(fiat_balance_sum))

    target_amount_fiat = {}
    for c in coins:
        target_amount_fiat[c] = fiat_balance_sum * weights[c]
    print("target_amount_fiat={}".format(target_amount_fiat))

    balance_differences_fiat = {}
    for c in coins:
        balance_differences_fiat[c] = round(target_amount_fiat[c] - fiat_balances[c], 2)
    print("balance_differences_fiat={}".format(balance_differences_fiat))

    # Calculate portion of each to buy
    sum_to_buy = 0
    for coin in balance_differences_fiat:
        if balance_differences_fiat[coin] >= 0:
            sum_to_buy += balance_differences_fiat[coin]
    amount_to_buy = {}
    for coin in balance_differences_fiat:
        amount_to_buy[coin] = (
            math.floor(
                100 * (balance_differences_fiat[coin] / sum_to_buy) * fiat_amount
            )
            / 100.0
        )

    print("amount_to_buy={}".format(amount_to_buy))

    for c in coins:
        place_buy_orders(
            args, amount_to_buy[c], coins, c, prices[c], cbpro_client, db_session
        )


def execute_withdrawal(cbpro_client, amount, currency, crypto_address, db_session):
    # The cbpro API does something goofy where the account balance
    # has more decimal places than the withdrawal API supports, so
    # we have to account for that here. Plus, the format()
    # function will round the float, so we have to do some
    # janky flooring.
    amount = "{0:.9f}".format(float(amount))[0:-1]
    print("withdrawing {} {} to {}".format(amount, currency, crypto_address))
    transaction = cbpro_client.crypto_withdraw(
        amount=amount, currency=currency, crypto_address=crypto_address
    )
    print("transaction={}".format(transaction))
    if "id" in transaction:
        db_session.add(
            Withdrawal(
                amount=amount,
                currency=currency,
                crypto_address=crypto_address,
                cbpro_withdrawal_id=transaction["id"],
            )
        )
        db_session.commit()


def withdraw(coins, accounts, cbpro_client, db_session):
    for coin in coins:
        if (
            "withdrawal_address" not in coins[coin]
            or coins[coin]["withdrawal_address"] is None
            or len(coins[coin]["withdrawal_address"]) < 1
        ):
            print("no {} withdraw address specified, " "not withdrawing".format(coin))
            continue
        account = get_account(accounts, coin)
        if float(account["balance"]) < 0.01:
            print(
                "{} balance only {}, not withdrawing".format(coin, account["balance"])
            )
        else:
            execute_withdrawal(
                cbpro_client,
                account["balance"],
                coin,
                coins[coin]["withdrawal_address"],
                db_session,
            )


def get_withdrawn_balances(db_session):
    from sqlalchemy import func

    withdrawn_balances = {}
    withdrawals = (
        db_session.query(func.sum(Withdrawal.amount), Withdrawal.currency)
        .group_by(Withdrawal.currency)
        .all()
    )
    for w in withdrawals:
        withdrawn_balances[w[1]] = w[0]
    return withdrawn_balances


def buy(args, coins, cbpro_client, db_session):
    print("starting buy and (maybe) withdrawal")
    print("first, cancelling orders")
    products = get_products(cbpro_client, coins, args.fiat_currency)
    print("products={}".format(products))
    for c in coins:
        cbpro_client.cancel_all(product_id="{}-{}".format(c, args.fiat_currency))
    # Check if there's any fiat available to execute a buy
    accounts = cbpro_client.get_accounts()
    prices = get_prices(cbpro_client, coins, args.fiat_currency)
    withdrawn_balances = get_withdrawn_balances(db_session)
    print("accounts={}".format(accounts))
    print("prices={}".format(prices))
    print("withdrawn_balances={}".format(withdrawn_balances))

    fiat_balances = get_fiat_balances(args, coins, accounts, withdrawn_balances, prices)
    print("fiat_balances={}".format(fiat_balances))

    fiat_amount = fiat_balances[args.fiat_currency]
    fee_amount = args.base_fee * fiat_amount
    print("reserving {} for fees, base_fee={}".format(fee_amount, args.base_fee))
    fiat_amount -= fee_amount

    if fiat_amount > args.withdrawal_amount:
        print(
            "fiat balance above {} {}, buying more".format(
                args.withdrawal_amount, args.fiat_currency
            )
        )
        start_buy_orders(
            args,
            coins,
            accounts,
            prices,
            fiat_balances,
            fiat_amount,
            cbpro_client,
            db_session,
        )
    else:
        print(
            "only {} {} fiat balance remaining, withdrawing"
            " coins without buying".format(fiat_amount, args.fiat_currency)
        )
        withdraw(coins, accounts, cbpro_client, db_session)


def main():
    default_coins = """
    {
      "BTC":{
        "name":"Bitcoin",
        "withdrawal_address":null,
        "external_balance":0
      },
      "ETH":{
        "name":"Ethereum",
        "withdrawal_address":null,
        "external_balance":0
      },
      "LTC":{
        "name":"Litecoin",
        "withdrawal_address":null,
        "external_balance":0
      }
    }
    """

    parser = argparse.ArgumentParser(
        description="Buy coins!",
        epilog="Default coins are as follows: {}".format(default_coins),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("--mode", help="mode (deposit or buy)", required=True)
    parser.add_argument("--amount", type=float, help="amount to deposit")
    parser.add_argument("--key", help="API key", required=True)
    parser.add_argument("--b64secret", help="API secret", required=True)
    parser.add_argument("--passphrase", help="API passphrase", required=True)
    parser.add_argument(
        "--api-url",
        help="API URL (default: https://api.pro.coinbase.com)",
        default="https://api.pro.coinbase.com",
    )
    parser.add_argument(
        "--payment-method-id", help="Payment method ID for fiat deposits"
    )
    parser.add_argument(
        "--starting-discount",
        type=float,
        help="starting discount (default: 0.005)",
        default=0.005,
    )
    parser.add_argument(
        "--discount-step",
        type=float,
        help="discount step between orders (default: 0.01)",
        default=0.01,
    )
    parser.add_argument(
        "--order-count", type=int, help="number of orders (default: 5)", default=5
    )
    parser.add_argument(
        "--fiat-currency", help="Fiat currency (default: USD)", default="USD"
    )
    parser.add_argument(
        "--withdrawal-amount",
        help="withdraw when fiat " "balance drops below this amount (default: 25)",
        type=float,
        default=25,
    )
    parser.add_argument(
        "--db-engine",
        help="SQLAlchemy DB engine " "(default: sqlite:///cbpro_history.db)",
        default="sqlite:///cbpro_history.db",
    )
    parser.add_argument(
        "--max-retries",
        help="Maximum number of times to "
        "retry if there are any failures, such as API issues "
        "(default: 3)",
        type=int,
        default=3,
    )
    parser.add_argument(
        "--coins",
        help="Coins to trade, minimum trade size,"
        " withdrawal addresses and external balances. "
        "Accepts a JSON string.",
        default=default_coins,
    )
    parser.add_argument(
        "--base-fee",
        help="Default base fee to subtract " "from overall balance.",
        type=float,
        default=0.0015,
    )

    args = parser.parse_args()
    coins = json.loads(args.coins)
    print("--coins='{}'".format(json.dumps(coins, separators=(",", ":"))))

    cbpro_client = cbpro.AuthenticatedClient(
        args.key, args.b64secret, args.passphrase, args.api_url
    )
    db_session = get_session(args.db_engine)

    retry = 0
    backoff = 5
    while retry < args.max_retries:
        retry += 1
        print("attempt {} of {}".format(retry, args.max_retries))
        try:
            if args.mode == "deposit":
                deposit(args, cbpro_client, db_session)
            elif args.mode == "buy":
                buy(args, coins, cbpro_client, db_session)
            sys.stdout.flush()
            sys.exit(0)
        except Exception as e:
            print("caught an exception: ", e)
            import traceback

            traceback.print_exc()
            sys.stderr.flush()
            sys.stdout.flush()
            print("sleeping for {}s".format(backoff))
            time.sleep(backoff)
            backoff = backoff * 2


if __name__ == "main":
    main()