piotrmurach/coinpare

View on GitHub
lib/coinpare/commands/holdings.rb

Summary

Maintainability
D
1 day
Test Coverage
A
94%
# frozen_string_literal: true

require "toml"
require "pastel"
require "tty-config"
require "tty-prompt"
require "tty-spinner"
require "tty-table"
require "tty-pie"
require "timers"

require_relative "../command"
require_relative "../fetcher"

module Coinpare
  module Commands
    class Holdings < Coinpare::Command
      DEFAULT_PIE_RADIUS = 6

      def initialize(options)
        @options = options
        @pastel = Pastel.new
        @timers = Timers::Group.new
        @spinner = TTY::Spinner.new(":spinner Fetching data...",
                                    format: :dots, clear: true)
        @interval = @options.fetch("watch", DEFAULT_INTERVAL).to_f
        config.set("settings", "color", value: !@options["no-color"])
      end

      def execute(input: $stdin, output: $stdout)
        config_saved = config.exist?
        if config_saved && @options["edit"]
          editor.open(config.source_file)
          return
        elsif @options["edit"]
          output.puts "Sorry, no holdings configuration found."
          output.print "Run \""
          output.print "$ #{add_color('coinpare holdings', :yellow)}\" "
          output.puts "to setup new altfolio."
          return
        end

        config.read if config_saved

        holdings = config.fetch("holdings")
        if holdings.nil? || (holdings && holdings.empty?)
          info = setup_portfolio(input, output)
          config.merge(info)
        elsif @options["add"]
          coin_info = add_coin(input, output)
          config.append(coin_info, to: ["holdings"])
        elsif @options["remove"]
          coin_info = remove_coin(input, output)
          config.remove(*coin_info, from: ["holdings"])
        elsif @options["clear"]
          prompt = create_prompt(input, output)
          answer = prompt.yes?("Do you want to remove all holdings?")
          if answer
            config.delete("holdings")
            output.puts add_color("All holdings removed", :red)
          end
        end

        holdings = config.fetch("holdings")
        no_holdings_left = holdings.nil? || (holdings && holdings.empty?)
        if no_holdings_left
          config.delete("holdings")
        end

        # Persist current configuration
        home_file = ::File.join(Dir.home, "#{config.filename}#{config.extname}")
        file = config.source_file
        config.write(file.nil? ? home_file : file, force: true)
        if no_holdings_left
          output.puts add_color("Please add holdings to your altfolio!", :green)
          exit
        end

        @spinner.auto_spin
        settings = config.fetch("settings")
        # command options take precedence over config settings
        overridden_settings = {}
        overridden_settings["exchange"] = @options.fetch("exchange", settings.fetch("exchange"))
        overridden_settings["base"]     = @options.fetch("base", settings.fetch("base"))
        holdings = config.fetch("holdings") { [] }
        names = holdings.map { |c| c["name"] }

        if @options["watch"]
          output.print cursor.hide
          @timers.now_and_every(@interval) do
            display_coins(output, names, overridden_settings)
          end
          loop { @timers.wait }
        else
          display_coins(output, names, overridden_settings)
        end
      ensure
        @spinner.stop
        if @options["watch"]
          @timers.cancel
          output.print cursor.clear_screen_down
          output.print cursor.show
        end
      end

      def display_coins(output, names, overridden_settings)
        response = Fetcher.fetch_prices(names.join(","),
                                        overridden_settings["base"].upcase,
                                        overridden_settings)
        return unless response
        table = if @options["pie"]
                  setup_table_with_pies(response["RAW"], response["DISPLAY"])
                else
                  setup_table(response["RAW"], response["DISPLAY"])
                end

        @spinner.stop

        lines = banner(overridden_settings).lines.size + 1 + (table.rows_size + 3)
        clear_output(output, lines) do
          output.puts banner(overridden_settings)
          if @options["pie"]
            output.puts table.render(:unicode, padding: [0, 2])
          else
            output.puts table.render(:unicode, padding: [0, 1], alignment: :right)
          end
        end
      end

      def clear_output(output, lines)
        output.print cursor.clear_screen_down if @options["watch"]
        yield if block_given?
        output.print cursor.up(lines) if @options["watch"]
      end

      def create_prompt(input, output)
        prompt = TTY::Prompt.new(
          prefix: "[#{add_color('c', :yellow)}] ",
          input: input, output: output,
          interrupt: -> { puts; exit 1 },
          enable_color: !@options["no-color"]
        )
        prompt.on(:keypress) { |event|
          prompt.trigger(:keydown) if event.value == "j"
          prompt.trigger(:keyup) if event.value == "k"
        }
        prompt
      end

      def ask_coin
        -> (prompt) do
          key("name").ask("What coin do you own?") do |q|
            q.default "BTC"
            q.required(true, "You need to provide a coin")
            q.validate(/\w{2,}/, "Currency can only be chars.")
            q.convert ->(coin) { coin.upcase }
          end
          key("amount").ask("What amount?") do |q|
            q.required(true, "You need to provide an amount")
            q.validate(/[\d.]+/, "Invalid amount provided")
            q.convert ->(am) { am.to_f }
          end
          key("price").ask("At what price per coin?") do |q|
            q.required(true, "You need to provide a price")
            q.validate(/[\d.]+/, "Invalid prince provided")
            q.convert ->(p) { p.to_f }
          end
        end
      end

      def add_coin(input, output)
        prompt = create_prompt(input, output)
        context = self
        data = prompt.collect(&context.ask_coin)
        output.print cursor.up(3)
        output.print cursor.clear_screen_down
        data
      end

      def remove_coin(input, output)
        prompt = create_prompt(input, output)
        holdings = config.fetch("holdings")
        data = prompt.multi_select("Which hodlings to remove?") do |menu|
          holdings.each do |holding|
            menu.choice "#{holding['name']} (#{holding['amount']})", holding
          end
        end
        output.print cursor.up(1)
        output.print cursor.clear_line
        data
      end

      def setup_portfolio(input, output)
        output.print "\nCurrently you have no investments setup...\n" \
                     "Let's change that and create your altfolio!\n\n"

        prompt = create_prompt(input, output)
        context = self
        data = prompt.collect do
          key("settings") do
            key("base").ask("What base currency to convert holdings to?") do |q|
              q.default "USD"
              q.convert ->(b) { b.upcase }
              q.validate(/\w{3}/, "Currency code needs to be 3 chars long")
            end
            key("exchange").ask("What exchange would you like to use?") do |q|
              q.default "CCCAGG"
              q.required true
            end
          end

          while prompt.yes?("Do you want to add coin to your altfolio?")
            key("holdings").values(&context.ask_coin)
          end
        end

        lines = 4 + # intro
                2 + # base + exchange
                data["holdings"].size * 4 + 1
        output.print cursor.up(lines)
        output.print cursor.clear_screen_down

        data
      end

      def create_pie_charts(raw_data, display_data)
        colors = %i[yellow blue green cyan magenta red]
        radius = @options["pie"].to_i > 0 ? @options["pie"].to_i : DEFAULT_PIE_RADIUS
        base   = @options.fetch("base", config.fetch("settings", "base")).upcase
        to_symbol = nil
        past_data = []
        curr_data = []

        config.fetch("holdings").each do |coin|
          coin_data = raw_data[coin["name"]][base]
          to_symbol = display_data[coin["name"]][base]["TOSYMBOL"]
          past_price = coin["amount"] * coin["price"]
          curr_price = coin["amount"] * coin_data["PRICE"]

          past_data << { name: coin["name"], value: past_price }
          curr_data << { name: coin["name"], value: curr_price }
        end

        options = {
          colors: !@options["no-color"] && colors,
          radius: radius,
          legend: {
            left: 2,
            format: "%<label>s %<name>s #{to_symbol} %<currency>s (%<percent>.0f%%)",
            precision: 2
          }
        }

        [
          TTY::Pie.new(**options.merge(data: past_data)),
          TTY::Pie.new(**options.merge(data: curr_data)),
          to_symbol
        ]
      end

      def setup_table_with_pies(raw_data, display_data)
        past_pie, curr_pie, to_symbol = *create_pie_charts(raw_data, display_data)

        total_change = curr_pie.total - past_pie.total
        arrow = pick_arrow(total_change)

        header_past = "Total Price (#{to_symbol} #{number_to_currency(round_to(past_pie.total))})"
        header_curr = [
          "Total Current Price (#{to_symbol} #{number_to_currency(round_to(curr_pie.total))}) ",
          add_color("#{arrow} #{to_symbol} #{number_to_currency(round_to(total_change))}", pick_color(total_change)),
        ].join

        table = TTY::Table.new(
          header: [
            { value: header_past, alignment: :center },
            { value: header_curr, alignment: :center }
          ]
        )
        past_pie.to_s.split("\n").zip(curr_pie.to_s.split("\n")).each do |past_part, curr_part|
          table << [past_part, curr_part]
        end
        table
      end

      def setup_table(raw_data, display_data)
        base = @options.fetch("base", config.fetch("settings", "base")).upcase
        total_buy = 0
        total = 0
        to_symbol = nil

        table = TTY::Table.new(header: [
          { value: "Coin", alignment: :left },
          "Amount",
          "Price",
          "Total Price",
          "Cur. Price",
          "Total Cur. Price",
          "Change",
          "Change%"
        ])

        config.fetch("holdings").each do |coin|
          coin_data = raw_data[coin["name"]][base]
          coin_display_data = display_data[coin["name"]][base]
          past_price = coin["amount"] * coin["price"]
          curr_price = coin["amount"] * coin_data["PRICE"]
          to_symbol = coin_display_data["TOSYMBOL"]
          change = curr_price - past_price
          arrow = pick_arrow(change)
          total_buy +=  past_price
          total += curr_price

          coin_details = [
            { value: add_color(coin["name"], :yellow), alignment: :left },
            coin["amount"],
            "#{to_symbol} #{number_to_currency(round_to(coin['price']))}",
            "#{to_symbol} #{number_to_currency(round_to(past_price))}",
            add_color("#{to_symbol} #{number_to_currency(round_to(coin_data['PRICE']))}", pick_color(change)),
            add_color("#{to_symbol} #{number_to_currency(round_to(curr_price))}", pick_color(change)),
            add_color("#{arrow} #{to_symbol} #{number_to_currency(round_to(change))}", pick_color(change)),
            add_color("#{arrow} #{round_to(percent_change(past_price, curr_price))}%", pick_color(change))
          ]
          table << coin_details
        end

        total_change = total - total_buy
        arrow = pick_arrow(total_change)

        table << [
          { value: add_color("ALL", :cyan), alignment: :left }, "-", "-",
          "#{to_symbol} #{number_to_currency(round_to(total_buy))}", "-",
          add_color("#{to_symbol} #{number_to_currency(round_to(total))}", pick_color(total_change)),
          add_color("#{arrow} #{to_symbol} #{number_to_currency(round_to(total_change))}", pick_color(total_change)),
          add_color("#{arrow} #{round_to(percent_change(total_buy, total))}%", pick_color(total_change))
        ]

        table
      end
    end # Holdings
  end # Commands
end # Coinpare