8
\$\begingroup\$

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 not 1 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.

\$\endgroup\$
2
  • 1
    \$\begingroup\$ I suspect the replace in your final line isn't doing what you want: e.g. convert_float_to_value(.51) returns '51 cent' \$\endgroup\$
    – Energya
    Commented Jun 12, 2020 at 9:36
  • \$\begingroup\$ @Energya that's a good point. I could use a regular expression return re.sub(r"^1 cents$", "1 cent", f"{round(number * 100):,} cents") or perhaps some logic that checks the output string is exactly 1 cents and then returns 1 cent. \$\endgroup\$ Commented Jun 12, 2020 at 10:49

2 Answers 2

1
\$\begingroup\$

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.

\$\endgroup\$
2
  • \$\begingroup\$ This is such a good answer, thanks! Could you update the link to Decimal floating point to Python 3 docs? \$\endgroup\$ Commented Jul 1, 2020 at 17:01
  • 1
    \$\begingroup\$ @MartinBurch Link updated. \$\endgroup\$
    – chux
    Commented Jul 1, 2020 at 17:09
2
\$\begingroup\$

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.

  1. Excluding cents; t[2], t[3] and t[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
    
  2. 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 to ints and keeps floats as floats.

  3. We haven't built t[3] and t[5] from t[2] for plain dollars as it still requires .2f to make sense. To deal with this we can make a function pretty_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 ""))
    
  4. With the above changes we can see t[5] and t[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?

  5. By changing the method of finding digits to use len we can replace float('-inf') with -2 and build t[1] from d.

  6. I am going to reorder the table so that they're grouped better. t[0] should be near t[5] and t[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}"
  1. We can see that TRANSFORMS is not normalized.

    1. All of prefix, plural and singular 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"

    2. 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.

  2. With your values fixed is the same as not 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}"
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.