Comments 9
в своё время приходилось переписывать с VBA на python+numpy+распараллеливание довольно много не самых простых алгоритмов типа моделирования долей рынка. и при этом быть самому себе тестировщиком. в итоге тоже пришёл к описанному выше решению. режем алгоритм на куски, в каждый кусок заправляем пачками тестовые наборы данных, проверяем результат, при недостаточном совпадении (ошибки округления поллучаются чуть разными!) дебажим. но чтобы это отдать внешним тестировщикам, нужно хотя бы на уровне соединенных проводами чёрных ящиков обучить тестировщика методологии расчёта. получится недешёвый тестировщик.
что то не понятно... почему сразу не использовать?
BigDecimal prc = new BigDecimal(10.75).setScale(2, RoundingMode.HALF_UP);
BigDecimal coef = new BigDecimal(365*100).setScale(0, RoundingMode.HALF_UP);
и подобно остальные переменные. тогда никаких "хвостов" не будет.
Это был пример как было сделано, и как возникла ошибка в тестах и в коде, одна и та же. Почему было не сделать по-другому? Видимо потому, что баги так и возникают :)
Тестирование калькуляторов - совсем нетривиальная задача.
Хотя сам продукт в применении довольно прост. Кнопок вроде не много а вот количество вводных и выходных данных огромно.
Мы тоже тестируем финтех (правда end-to-end), но таких проблем как у вас нам удалось избежать.
Для начала: любое число в финтехе хранится с заданной точностью:
для денег это два знака
для количества (товара, акций и т.д.) точность может варьироваться, но в любом случае она задана на уровне спецификации
Если в БД хранить какой-нибудь Float или Double как есть, то расчёты могут никогда не сойтись. А ведь может прийти клиент и спросить почему его и ваши расчёты не совпадают.
Также в расчётах всегда есть округления, для каждого случая правило округления должно быть явно указано.
Зная это, проблема сверки расчётов (спецификация vs код написанный программистом) не выглядит нерешаемой задачей.
Перед тем как пойти дальше приведу ещё пару особенностей BigDecimal (примеры будут на Kotlin):
/* особенности перевода Float/Double -> BigDecimal */
val x: Double = 10.1
// действительно
println(BigDecimal(x)) // 10.0999999999999996447286321199499070644378662109375
// но при этом
println(x.toBigDecimal()) // 10.1
/* особенности деления */
val x = BigDecimal("2.20")
println(x / BigDecimal(3)) // 0.73 (неожиданно(?) окрулилось до 2-х знаков)
println(x.divide(BigDecimal(3), 10, RoundingMode.FLOOR)) // 0.7333333333 (контролируемое округление)
Чтобы избежать использования "неправильных" функций BigDecimal, мы просто реализовали свой тип обёртку, который назвали Decimal. Пример из статьи с учётом нашего типа можно записать так:
val result = 100_000_000 * (1 + (Decimal(10.75) / 36_500).roundDown(10))
println(result) // 100029452.05
Как видите, запись получилась человекочитаемая, а результат сходится с ожидаемым.
И я перепроверил ваш пример (правда на Kotlin) и результат тоже сходится, ошибка была где-то в другом месте...
val prc = BigDecimal("10.75")
val qty = BigDecimal("100000000.0")
val coef = BigDecimal(365*100) // 36_500
val tmp1 = prc.divide(coef, 10, RoundingMode.FLOOR) // 0.0002945205
val tmp2 = BigDecimal.ONE.add(tmp1) // 1.0002945205
val finalResult = qty.multiply(tmp2)
println(finalResult) // 100029452.05000000000 (у вас 100_029_452.0547945000000000)
По поводу того, что использовать Excel или программирование: для unit-тестов -- Excel, для e2e-тестов (наш случай) -- программирование (у нас безумное число расчётов на каждый сценарий, а ещё плавающий прайс). Для интеграционных тестов у меня нет ответа, т.к. не сталкивался с ними.
P.S. Если есть какие-то вопросы можете написать в личку.
О, очень крутой коммент. В примере должно получиться 6 копеек, у Вас 5 как я поняла. Проблема подобной обертки, что нам нужно округления в зависимости от стороны сделки (продажа или покупка), это становится известно только в тесте, соответственно нельзя просто написать обертку для расчетов, в неё надо будет прокидывать доп данные из бизнеса, но опыт интересный, спасибо
Вы в статье вот тут потеряли точность:
BigDecimal tmp1 = prc.divide(coef, 10, RoundingMode.FLOOR); // 0.0002945205
При этом finalResult у вас будет именно 100_029_452.05000000000
, а не как указано в статье 100_029_452.0547945000000000
.
У нас при делении Decimal идёт натуральное округление до 12-ти знаков (мы для себя определили, что этого достаточно) и получается:
println(Decimal(10.75) / 36_500) // 0.000294520548
Соответственно для buy/sell округления правильные формулы с нашим Decimal будут выглядеть так:
val result = (100_000_000 * (1 + Decimal(10.75) / 36_500))
println("Orig: " + result) // Orig: 100029452.0548
println("Buy: " + result.roundUp(2)) // Buy: 100029452.06
println("Sell: " + result.roundDown(2)) // Sell: 100029452.05
Не ругайтесь если я buy/sell перепутал местами, это зависит от того с какой стороны вы в расчётах находитесь :)
А смысл нашей обёртки вы поняли неверно, на самом деле она служит всего двум целям:
избежать ошибок при работе с BigDecimal (таких как BigDecimal(10.1) vs 10.1.toBigDecimal() из предыдущего комментария)
сохранить простоту написания и читаемость формул
Другими словами: мы просто чуть-чуть развернули язык (в нашем случае Kotlin) лицом к тестировщику.
Предлагаю использовать округление с учетом погрешности. Это когда идет подряд несколько округлений, то каждое следующее округление делается с учетом той "отброшенной" на предыдущем округлении частички.
Блеск и нищета автоматизации тестирования расчетов в финтехе