#!/usr/bin/env ruby

# Copyright 2010, 2011 Luther Thompson

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

# Version 0.3.0

$VERBOSE = true

require 'optparse'

# Stores a running total for all trades of a currency. Can report high, low, and
# average.
class RunningTotal

  include Math

  # Stores data for an individual trade
  class BTCPrice

    attr_reader :price

    # Arguments are the price and time of the trade
    def initialize price, time
      @price = price
      @time = time
    end

    def to_s
      priceString = sprintf '%-9g', @price
      timeString = @time.strftime '%Y-%_m-%e %a %k:%M'
      "#{priceString} #{timeString}"
    end

  end

  # Starts off empty
  def initialize
    @low = nil
    @high = nil
    @sum = 0
    @volume = 0
  end

  # Processes a new trade.
  # price: The price in bitcoins
  # time: Time of the trade
  # volume: Amount of bitcoins in the trade
  def add price, time, volume
    priceData = BTCPrice.new price, time

    if @low == nil or priceData.price < @low.price
      @low = priceData
    end
    if @high == nil or priceData.price > @high.price
      @high = priceData
    end

    # Used for calculating the average. As written, it gives a higher weight to
    # when BTC are low. If I convert the quantity from BTC to the commodity, it
    # gives a higher weight to when the commodity is low.
    volume = volume.to_f
    @sum += priceData.price * volume
    @volume += volume
  end

  def to_s
    if @volume == 0
      return 'no volume'
    end
    average = @sum / @volume
    uncertainty = @high.price - @low.price
    precision =
      if uncertainty == 0
        6
      else
        [log10(average).ceil - log10(uncertainty).floor, 1].max
      end

    result =          "High:    #@high\n"
    result <<         "Low:     #@low\n"
    result << sprintf("Average: %.#{precision}g\n", average)
  end

end

class String

  # If separate is true, return self, otherwise return a currency symbol derived
  # from self.
  def marketSymbol separate
    if separate
      self
    else
      currency = self[-3..-1]
      case currency
      when 'WMR', 'YAD'
        'RUB'
      when 'WMZ'
        'USD'
      else
        currency
      end
    end
  end

end

# Downloads JSON data of all trades and records the time in
# "#{UserDataDir}/last-access". DO NOT call this method more than once every 15
# min.
def getJsonData
  File.open("#{UserDataDir}/last-access", 'w') { |f|
    f.puts Now.to_i
  }
  print 'Downloading JSON data...'
  File.open("#{UserDataDir}/trades.json", 'w') { |f|
    # The 'since' parameter doesn't seem to have any effect, even though it's
    # documented in the API. The 'limit' here is arbitrary and may have to be
    # increased in the future to get all trades. The first trade should be on
    # 2010-4-30.
    # FIXME: Rescue SocketError, OpenURI::HTTPError
    f.puts open('http://bitcoincharts.com/t/lasttrades.json?limit=100000').read
  }
  puts 'done'
end

# Which source data to use
dataFormat = :json
# Treat markets as separate currencies
separate = false

op = OptionParser.new { |op|
  op.on('-c', '--csv') {
    dataFormat = :csv
  }
  op.on('-j', '--json') {
    dataFormat = :json
  }
  op.on('-s', '--[no-]separate') { |o|
    separate = o
  }
}
op.parse!

# Store the present time to a constant to make sure it's the same everywhere
Now = Time.new

argc = ARGV.length
if argc == 0
  # No time given. Use entire history.
  start = Time.at 0
  finish = Now
else
  ARGV.collect! { |arg|
    arg.to_i
  }
  begin
    start = Time.new *ARGV
  rescue ArgumentError
    puts 'Usage:'
    puts 'btctrade [year [month [day [hour [minute [second]]]]]]'
    puts 'All arguments must be numbers'
    exit
  end
  year = ARGV[0]
  if argc == 1
    # Year
    finish = Time.new year + 1
  else
    # Number of seconds between start and finish
    period = 1
    if argc == 2
      # Month
      LeapYearMonthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
      CommonYearMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
      month = ARGV[1]
      period =
        if year % 4 == 0 and year % 100 != 0 or year % 400 == 0
          LeapYearMonthDays[month - 1]
        else
          CommonYearMonthDays[month - 1]
        end
    end
    if argc <= 3
      # Day
      period *= 24
    end
    if argc <= 4
      # Hour
      period *= 60
    end
    if argc <= 5
      # Minute
      period *= 60
    end
    finish = start + period
  end
end

if start > Now
  warn 'warning: Period is in the future'
end

totals = Hash.new { |hash, key|
  hash[key] = RunningTotal.new
}

case dataFormat
when :csv

  require 'csv'

  ZipFile = 'trades.zip'
  DataFile = 'trades.csv'

  if not File.exist? DataFile
    if File.exist? ZipFile
      system "unzip #{ZipFile}"
    else
      puts
      ("Please download the file at <http://www.bitcoinwatch.com/#{ZipFile}>")
      exit
    end
  end

  csvOptions = {
    headers: true,
    converters: :numeric,
    header_converters: :symbol
  }

  CSV.foreach(DataFile, csvOptions) { |row|
    # Assumptions about the source data:
    # Reverse chronological order.
    # 'quantity' is the amount traded, in BTC.

    time = Time.at row[:datetime]
    if time < start
      break
    elsif time < finish
      key = row[:currency].marketSymbol separate
      totals[key].add 1 / row[:price], time, row[:quantity]
    end
  }

when :json

  require 'json'
  require 'open-uri'

  UserDataDir = "#{ENV['HOME']}/.btctrade"

  if not File.directory? UserDataDir
    system "mkdir -v #{UserDataDir}"
    getJsonData
  end

  # If the last network access was more than 15 min ago, we may download the
  # data again.
  lastAccess = nil
  File.open("#{UserDataDir}/last-access", 'r') { |f|
    lastAccess = Time.at f.gets.to_i
  }
  if lastAccess < finish and Now > lastAccess + 900
    getJsonData
  end

  data = File.read "#{UserDataDir}/trades.json"
  # FIXME: rescue JSON::ParserError
  trades = JSON.parse data, {symbolize_names: true}

  trades.each { |trade|
    time = Time.at trade[:timestamp]
    if time < start
      break
    elsif time < finish
      key = trade[:symbol].marketSymbol separate
      totals[key].add 1 / trade[:price].to_f, time, trade[:volume]
    end
  }

else
  puts "Error: 'dataFormat' has unknown value"
end

totals.keys.sort.each { |name|
  puts name
  puts totals[name]
  puts
}
