Given a floating-point number, which is representing a dollar value, I'm aiming to return a rounded, human-readable string. Here are the rules for numbers equal to or greater than 1 million, equal to or greater than 1 but less than 1 million, and, finally, less than 1:
- one decimal place for million, two places for billions and three places for trillions
- numbers over 999 trillion are displayed in trillions, e.g.
1,000 trillion
not1 quadrillion
- if less than 1 million, two decimal places (cents)
- if less than 1, then as cents (multiplied by 100)
- numbers which don't round up to at least 1 cent count as 0 cents
Also, for numbers greater than 1, there's no display of precision if mantissa is all zeros (e.g. 1.000 trillion
becomes 1 trillion
and 1.00
becomes 1
.)
import math
def convert_float_to_value(number):
if number == 0.0:
return "0 cents"
digits = 1 + math.floor(math.log10(number))
if digits > 12:
return f"${round(number / 10**12, 3):,.3f} trillion".replace(".000", "")
elif digits > 9:
return f"${round(number / 10**9, 2):,.2f} billion".replace(".00", "")
elif digits > 6:
return f"${round(number / 10**6, 1):,.1f} million".replace(".0", "")
elif digits > 0:
return f"${round(number, 2):,.2f}".replace(".00","")
else:
return f"{round(number * 100):,} cents".replace("1 cents", "1 cent")
I already sense some improvements, like perhaps I should be checking this really is a floating-point value and not an integer, or just turning whatever I get into a float. But what do you think of this approach? There are a number of pretty-printing libraries but these are some very specific rules.
2 Answers 2
But what do you think of this approach?
Comments below are less about Python and more about design and test.
Negatives
I suggest using the absolute value of number
rather than math.log10(number)
.
Lack of edge case testing
I'd expect code to have been tested with various edge cases as they are specified in the requirements.
999000000000.0, 100000000000.0, 1000000000.0, 1000000.0, 1.0, 0.01, 0.005, math.nextafter(0.0,1.0)
And importantly the above values' next smallest value.
smaller= math.nextafter(x, 0.0)
Also test with negatives and very large values like \10ドル^{20}\$.
IMO, I think OP will find unacceptable results.
Conceptual alternative code
Rather than adding 1 and using >
, I would use >=
. This works even if math.floor()
is omitted.
# digits = 1 + math.floor(math.log10(number))
# if digits > 12:
digits = math.floor(math.log10(number))
if digits >= 12:
This better matches the coding goal.
Pedantic
number / 10**12
will impart a rounding error in the quotient before printing, which is in effect a 2nd rounding. When output is abc.def, values near abc.def +/- 0.0005 (scaled to \10ドル^{12}\$) may exhibit an incorrect rounding of the original number
. A solution is to round once converting the number
to text, which is later manipulated. Rarely is this degree of correctness needed.
Money
For money, I'd expect using Decimal
floating point. Unclear to me what OP is using given my scant Python experiences.
-
\$\begingroup\$ This is such a good answer, thanks! Could you update the link to Decimal floating point to Python 3 docs? \$\endgroup\$Martin Burch– Martin Burch2020年07月01日 17:01:22 +00:00Commented Jul 1, 2020 at 17:01
-
1\$\begingroup\$ @MartinBurch Link updated. \$\endgroup\$chux– chux2020年07月01日 17:09:44 +00:00Commented Jul 1, 2020 at 17:09
To me your code looks very WET. Everything is using the same core but with minor tweeks here and there. And so we can convert your code to a for loop.
TRANSFORMS = [
( 12, "$", 10**12, 3, ".3f", " trillion", ".000", ""),
( 9, "$", 10** 9, 2, ".2f", " billion", ".00", ""),
( 6, "$", 10** 6, 1, ".1f", " million", ".0", ""),
( 0, "$", 10** 0, 2, ".2f", "", ".00", ""),
(float("-inf"), "", 10**-2, None, "", " cents", "1 cents", "1 cent"),
]
def convert_float_to_value(number):
if number == 0.0:
return "0 cents"
digits = 1 + math.floor(math.log10(number))
for d, *t in TRANSFORMS:
if digits > d:
return f"{t[0]}{round(number / t[1], t[2]):,{t[3]}}{t[4]}".replace(t[5], t[6])
From here we can start to see patterns and other improvements.
Excluding cents;
t[2]
,t[3]
andt[5]
all have the same size. This means we can build them all from one value.v = ... t[2] = v t[3] = f".{v}f" t[4] = "." + "0"*v
It makes little sense to use
.2f
for billions. I've never, until this day, seen "1ドル.10 billion". It's almost like you've used the wrong word and meant to say "dollars".To fix this I'd stop using
.2f
. However this will break how.replace
works, and can add some hard to fix bugs. (Converting 10.01 to 101) To fix this I'd convert integers toint
s and keeps floats as floats.We haven't built
t[3]
andt[5]
fromt[2]
for plain dollars as it still requires.2f
to make sense. To deal with this we can make a functionpretty_round
that takes a number, a number of digets to round to, an option for the output to be a fixed amount of decimal places and any additional formatting. The only thing is that the output has return a string and a float.def pretty_round(number, ndigits=None, fixed=False, format_spec=""): number = round(number, ndigits) if number % 1 == 0: return number, format(int(number), format_spec) return number, format(number, format_spec + (f".{ndigits}f" if fixed else ""))
With the above changes we can see
t[5]
andt[6]
are only there to convert cents from plural to singular. And so why not just check if the output is singular or plural and append the correct term?By changing the method of finding
digits
to uselen
we can replacefloat('-inf')
with-2
and buildt[1]
fromd
.I am going to reorder the table so that they're grouped better.
t[0]
should be neart[5]
andt[6]
. And we can now give them easy to understand names.
TRANSFORMS = [
(12, 3, False, " trillion", "$", "", ""),
( 9, 2, False, " billion", "$", "", ""),
( 6, 1, False, " million", "$", "", ""),
( 0, 2, True, "", "$", "", ""),
(-2, None, False, "", "", " cents", " cent"),
]
def pretty_round(number, ndigits=None, fixed=False, format_spec=""):
number = round(number, ndigits)
if number % 1 == 0:
return number, format(int(number), format_spec)
return number, format(number, format_spec + (f".{ndigits}f" if fixed else ""))
def convert_float_to_value(number):
digits = len(str(int(number)).lstrip('0-'))
for exp, dp, fixed, magnitude, prefix, plural, singular in TRANSFORMS:
if digits > exp:
number_f, number_s = pretty_round(number / 10**exp, dp, fixed, ",")
name = singular if number_f == 1 and exp <= 0 else plural
return f"{prefix}{number_s}{magnitude}{name}"
We can see that
TRANSFORMS
is not normalized.All of
prefix
,plural
andsingular
are refering to dollers and cents. This can be extracted out into it's own table. Not only does it reduce how WET your code is it allows us to replace dollars with other monetary notations. Additionally it allows making changes easier if you want to change from using "$" to "dollars"The magnitude can change even in English. This is as 1 trillion can equal 1 billion, depending on which scale you use. Having this baked into the
TRANSFORMS
table can be problematic in the future.
With your values
fixed
is the same asnot exp
.
SHORT_SCALE = [
"",
" thousand",
" million",
" billion",
" trillion",
]
USD = [
("$", "", ""),
("", " cents", " cent"),
]
TRANSFORMS = [
(12, 3),
( 9, 2),
( 6, 1),
( 0, 2),
(-2, 0),
]
def pretty_round(number, ndigits=None, fixed=False, format_spec=""):
number = round(number, ndigits)
if number % 1 == 0:
return number, format(int(number), format_spec)
return number, format(number, format_spec + (f".{ndigits}f" if fixed else ""))
def convert_float_to_value(number, *, scale=SHORT_SCALE, currency=USD):
digits = len(str(int(number)).lstrip('0-'))
for exp, dp in TRANSFORMS:
if digits > exp:
number_f, number_s = pretty_round(number / 10**exp, dp, not exp, ",")
prefix, plural, singular = currency[exp < 0]
name = singular if number_f == 1 and exp <= 0 else plural
return f"{prefix}{number_s}{scale[int(exp / 3)]}{name}"
Explore related questions
See similar questions with these tags.
convert_float_to_value(.51)
returns'51 cent'
\$\endgroup\$return re.sub(r"^1 cents$", "1 cent", f"{round(number * 100):,} cents")
or perhaps some logic that checks the output string is exactly1 cents
and then returns1 cent
. \$\endgroup\$