I'm testing a small and simple library I made in Ruby. The goal is to convert from EUR to CNY and vice versa. Simple.
I tested it to be sure everything works but I got an unexpected issue. When I use to_euro followed by to_yuan it should go back to the original amount ; it doesn't happen. I tried to .to_f or round(2) the amount variable which fix some tests, raise new ones, but it's never equal to what I expect globally ; I'm running out of idea to fix this :(
class Currency
attr_reader :amount, :currency
def initialize(amount, currency='EUR')
#amount = amount
#currency = currency
end
def to_yuan
update_currency!('CNY', amount * Settings.instance.exchange_rate_to_yuan)
end
def to_euro
update_currency!('EUR', amount / Settings.instance.exchange_rate_to_yuan)
end
def display
"%.2f #{current_symbol}" % amount
end
private
def current_symbol
if currency == 'EUR'
symbol = Settings.instance.supplier_currency.symbol
elsif currency == 'CNY'
symbol = Settings.instance.platform_currency.symbol
end
end
def update_currency!(new_currency, new_amount)
unless new_currency == currency
#currency = new_currency
#amount = new_amount
end
self
end
end
Tests
describe Currency do
let(:rate) { Settings.instance.exchange_rate_to_yuan.to_f }
context "#to_yuan" do
it "should return Currency object" do
expect(Currency.new(20).to_yuan).to be_a(Currency)
end
it "should convert to yuan" do
expect(Currency.new(20).to_yuan.amount).to eql(20.00 * rate)
end
it "should convert to euro and back to yuan" do
# state data test
currency = Currency.new(150, 'CNY')
expect(currency.to_euro).to be_a(Currency)
expect(currency.to_yuan).to be_a(Currency)
expect(currency.amount).to eql(150.00)
end
end
context "#to_euro" do
it "should convert to euro" do
expect(Currency.new(150, 'CNY').to_euro.amount).to eql(150 / rate)
end
end
context "#display" do
it "should display euros" do
expect(Currency.new(10, 'EUR').display).to eql("10.00 €")
end
it "should display yuan" do
expect(Currency.new(60.50, 'CNY').display).to eql("60.50 ¥")
end
end
end
And here's my RSpec result
I'm pretty sure this problem is very common, any idea how to solve it easily ?
Float isn't an exact number representation, as stated in the ruby docs:
Float objects represent inexact real numbers using the native architecture's double-precision floating point representation.
This not ruby fault, as floats can only be represented by a fixed number of bytes and therefor cannot store decimal numbers correctly.
Alternatively, you can use ruby Rational or BigDecimal
Its is also fairly common to use the money gem when dealing with currency and money conversion.
Related
A follow up on the question How to create a random time between a range
.
Kernel#rand works with Time range:
require 'time'
rand(Time.parse('9 am')..Time.parse('11:30 am'))
But when I tried with a custom class, I ended up with the error:
`rand': no implicit conversion of Range into Integer (TypeError)
class Int
include Comparable
attr_reader :num
def initialize(num)
#num = num
end
def succ
Int.new(num + 1)
end
def <=>(other)
num <=> other.num
end
def to_s
"Int(#{num})"
end
def to_int
#num
end
alias_method :inspect, :to_s
end
puts rand(Int.new(1)..Int.new(3))
Why? What am I missing in the custom class? Can we use such a custom class in rand(Range)?
I don't know of any documentation for what specifically Kernel#rand expects from a Range argument but we can get a look at what's going on by overriding respond_to? in your class and then watching as things fall apart:
def respond_to?(m)
puts "They want us to support #{m}"
super
end
Doing that tells us that rand wants to call the #- and #+ methods on your Int instances. This does make some sense given that rand(a..b) is designed for working with integers.
So we throw in quick'n'dirty implementations of addition and subtraction:
def -(other)
self.class.new(to_int - other.to_int)
end
def +(other)
self.class.new(to_int + other.to_int)
end
and we start getting rand Ints out of our calls to rand.
I'm not sure where (or if) this is documented so you'll have to excuse a bit of hand waving. I normally spend some time rooting around the Ruby source code to answer this sort of question but I lack the time right now.
To add a bit more to #mu-is-too-short's answer, I checked the source of Random#rand and the following is the current implementation logic for rand(Range):
Get the begin, end, and vmax from the Range object (call range_values), where vmax is computed as (call id_minus):
vmax = end - begin
vmax will be used as the upper bound of the random number generation later.
This requires the custom class to have - method defined.
Generate a random number based on the type of vmax:
If it is not Float and can be coerced to Integer (rb_check_to_int), generate a random Integer less than vmax.
In this case, the - method should either return an Integer, or an object which responds to to_int method.
If it is Numeric and can be converted to Float with to_f, (rb_check_to_float), generate a random Float number less than vmax.
In this case, the - method should return a Numeric number which can be converted to Float with method to_f.
Add the random number to begin to yield the result (call id_add).
This requires the custom class to have + method defined, which accepts the result of the random number generated in step 2 (either Integer, or Float) and returns the final result for rand.
I believe this error is because you are trying to use rand() on objects of your custom class.
`rand': no implicit conversion of Range into Integer (TypeError)
This error message clearly mentions that ruby was unable to convert your range into integer. Based on your code snippet, following works and might be what you are looking for.
puts rand(Int.new(1).to_int..Int.new(3).to_int)
Why does the following method return infinity when trying to find the average volume of a stock:
class Statistics
def self.averageVolume(stocks)
values = Array.new
stocks.each do |stock|
values.push(stock.volume)
end
values.reduce(:+).to_f / values.size
end
end
class Stock
attr_reader :date, :open, :high, :low, :close, :adjusted_close, :volume
def initialize(date, open, high, low, close, adjusted_close, volume)
#date = date
#open = open
#high = high
#low = low
#close = close
#adjusted_close = adjusted_close
#volume = volume
end
def close
#close
end
def volume
#volume
end
end
CSV.foreach(fileName) do |stock|
entry = Stock.new(stock[0], stock[1], stock[2], stock[3], stock[4], stock[5], stock[6])
stocks.push(entry)
end
Here is how the method is called:
Statistics.averageVolume(stocks)
Output to console using a file that has 251 rows:
stock.rb:32: warning: Float 23624900242507002003... out of range
Infinity
Warning is called on the following line: values.reduce(:+).to_f / values.size
When writing average functions you'll want to pay close attention to the possibility of division by zero.
Here's a fixed and more Ruby-like implementation:
def self.average_volume(stocks)
# No data in means no data out, can't calculate.
return if (stocks.empty?)
# Pick out the `volume` value from each stock, then combine
# those with + using 0.0 as a default. This forces all of
# the subsequent values to be floating-point.
stocks.map(&:volume).reduce(0.0, &:+) / values.size
end
In Ruby it's strongly recommended to keep variable and method names in the x_y form, like average_volume here. Capitals have significant meaning and indicate constants like class, module and constant names.
You can test this method using a mock Stock:
require 'ostruct'
stocks = 10.times.map do |n|
OpenStruct.new(volume: n)
end
average_volume(stocks)
# => 4.5
average_volume([ ])
# => nil
If you're still getting infinity it's probably because you have a broken value somewhere in there for volume which is messing things up. You can try and filter those out:
stocks.map(&:value).reject(&:nan?)...
Where testing vs. nan? might be what you need to strip out junk data.
I story money values as integers like, price_in_cents or fee_in_cents. So, $1.00 would be stored in the database as 100.
Internally, I've always just typed 100 in the form when I meant 1.00, but I thought it would be simple to convert a form input of 1.00 to 100 in the model, but I'm having trouble.
No matter what I enter, I'm getting 0 for both values. Here's the model:
before_create :convert_money_to_cents
attr_accessor :price, :fee
private
def convert_money_to_cents
self.price_in_cents = price.to_i * 100
self.fee_in_cents = fee.to_i * 100
end
Any thoughts on why it's always coming up zero? I tried converting the string to various things (floats) and I'm still getting zeros.
Try this:
def convert_money_to_cents
self.price_in_cents = (self.price.to_d * 100).to_i
self.fee_in_cents = (self.fee.to_d * 100).to_i
end
I would probably do something like:
class Numeric
def to_cents
(self.to_f * 100).to_i
end
end
I am getting the following rounding error when I try to Unit test the class below:
class TypeTotal
attr_reader :cr_amount, :dr_amount,
:cr_count, :dr_count
def initialize()
#cr_amount=Float(0); #dr_amount=Float(0)
#cr_count=0; #dr_count= 0
end
def increment(is_a_credit, amount, count=1)
case is_a_credit
when true
#cr_amount = Float(amount)+ Float(#cr_amount)
#cr_count += count
when false
#dr_amount = Float(amount)+ Float(#dr_amount)
#dr_count += count
end
end
end
Unit Test:
require_relative 'total_type'
require 'test/unit'
class TestTotalType < Test::Unit::TestCase
#rounding error
def test_increment_count()
t = TypeTotal.new()
t.increment(false, 22.22, 2)
t.increment(false, 7.31, 3)
assert_equal(t.dr_amount, 29.53)
end
end
Output:
1) Failure:
test_increment_count(TestTotalType) [total_type_test.rb:10]:
<29.529999999999998> expected but was
<29.53>.
1 tests, 1 assertions, 1 failures, 0 errors, 0 skips
I am using Floats because it was recommended in the Pick Ax book for dollar values because they shouldn't be effective by round errors.
I am running on ruby 1.9.2p290 (2011-07-09) [i386-mingw32] on Windows 7 64-bit Home and Windows XP 32-bit Pro.
I've tried
casting my variables to floats
removing += and spelling out increment
The behavior appears random:
12.22 + 7.31 works
11.11 + 7.31 doesn't work
11.111 + 7.31 works
Any ideas whats going wrong?
Are you sure that was the advice given? I'd expect the advice to be not to use Floats, precisely because they use binary floating point arithmetic, and so are prone to rounding errors. From the Float documentation:
Float objects represent inexact real numbers using the native architecture's double-precision floating point representation.
If you could quote the exact advice you're referring to, that would help.
I would suggest you use BigDecimal instead, or use an integer with implicit units of "cents" or "hundreds of a cent" or something similar.
The problems with float are already mentioned.
When you test with floats, you should not use assert_equal but assert_in_delta.
Example:
require 'test/unit'
class TestTotalType < Test::Unit::TestCase
TOLERANCE = 1E-10 #or another (small) value
#rounding error
def test_increment_count()
t = TypeTotal.new()
t.increment(false, 22.22, 2)
t.increment(false, 7.31, 3)
#~ assert_equal(t.dr_amount, 29.53) #may detect float problems
assert_in_delta(t.dr_amount, 29.53, TOLERANCE)
end
end
The solution was to use Big Decimal:
require 'bigdecimal'
class TypeTotal
attr_reader :cr_amount, :dr_amount,
:cr_count, :dr_count
def initialize()
#cr_amount=BigDecimal.new("0"); #cr_count=0,
#dr_amount=BigDecimal.new("0"); #dr_count=0
end
def increment(is_a_credit, amount, count=1)
bd_amount = BigDecimal.new(amount)
case is_a_credit
when true
#cr_amount= bd_amount.add(#cr_amount, 14)
#cr_count += count
when false
#dr_amount= bd_amount.add(#dr_amount, 14)
#dr_count = count
end
end
The Pick Ax (p53) book used float for currency as an example but had a foot note explaining that you need to either add .5 cent to when you display the value or use Big Decimal.
Thanks for are your help!
I want to convert a subtitle time code:
begin="00:00:07.71" dur="00:00:03.67
to pure seconds:
begin=7.1 end=11.38
I wrote a Ruby code:
def to_sec(value)
a = value.split(':')
a[0].to_i*3600+a[1].to_i*60+a[2].to_f
end
which resulted in 11.379999999999999.
Can anybody tell me why this happens?
Is there any Time library that can do this conversion?
It'll probably be easiest for you to represent your underlying datatype as integer hundredths of a second (centiseconds):
def to_csec(value) #if you had CSec < Integer this would be `def self.[](value)`
a = value.split(':')
#tacking on a couple zeros to each
a[0].to_i*360000+a[1].to_i*6000+(a[2].to_f * 100).to_i
end
You could add some helpers for dealing with the durations and pretty printing them as well:
def csec_to_s(csec) #if you had CSec < Integer, this would be `def to_sec`
"%.2f" % (csec.to_f / 100)
end
class SubtitleDuration < Range
def initialize(a,b)
centi_a = to_csec(a)
super(centi_a,to_csec(b) + centi_a)
end
def to_s
"begin=#{csec_to_s(self.begin)} end=#{csec_to_s(self.end) }"
end
end
Then your answer is just:
puts SubtitleDuration.new("00:00:07.71", "00:00:03.67").to_s
#=> begin=7.71 end=11.38
This sort of thing can happen in just about any programming language. It's because of how floating point numbers are represented. They're not stored as decimals under the hood, so sometimes you get odd rounding errors like this.