
Когда вы открываете Яндекс Go для заказа такси и вводите адрес, приложение за доли секунды показывает цену. Кажется, что это несложно: взять расстояние и время в пути, умножить их на значения из тарифа. На самом деле, за этой ценой стоит не один десяток факторов — например, скидки, спрос, геозоны. И это ещё без учёта того, что пассажир взял с собой кота или лыжи.
В этой статье — о том, как мы вынесли алгоритм ценообразования из кода сервиса, почему не взяли готовый скриптовый язык, что такое цепочка преобразований цены и зачем нам понадобился формальный верификатор.
Прайсинг не заканчивается на офере: где ещё считается цена
Расчёт цены начинается в момент, когда пользователь открывает приложение и указывает точки А и Б. Прайсинг получает набор параметров — список тарифных категорий, точки назначения и другие данные о поездке — и возвращает цену по каждой категории. Этот первый расчёт мы называем офером: пользователь видит цены, выбирает тариф и подтверждает заказ.

Заканчивается ли на этом участие прайсинга в дальнейшей судьбе заказа? Конечно, нет. Пассажир мог перепутать Ленинский проспект и улицу Ленина и попросил поменять точку назначения. Или понял, что нужно заехать в магазин по дороге. Или оказалось, что на карте нет денег, — придётся переключиться на наличные, и тогда пропадёт скидка от банка‑партнёра. Во всех этих случаях прайсинг пересчитывает цену для активного заказа.
Может ли цена измениться только в процессе поездки? Нет. Например, пользователь попросил остановить машину за квартал до точки назначения. Или офер считался с учётом платной дороги, но по факту по ней не поехали. Поэтому при завершении заказа таксометр делает запрос в прайсинг: нужно пересчитать цену по актуальным параметрам поездки. Если таксометр не смог достучаться до прайсинга, например из‑за плохого интернета, водитель звонит в диспетчерскую, и заказ закрывают через неё.
У водителя и пользователя могут возникнуть вопросы по цене. Пользователь может посмотреть детализацию — её тоже генерирует прайсинг — или обратиться в поддержку. Поддержка также идёт в прайсинг за подробностями. Если выяснится, что цена посчитана некорректно — например, не применилась скидка за заказ, — оператор может пересчитать её, исправив только нужные параметры.
И наконец, ценой интересуются не только водитель и пользователь. Аналитики, которые считают влияние цены на назначение водителей, тоже идут в прайсинг.
Упрощённо схему всех обращений в прайсинг можно представить так:

Виды цен: чем они отличаются и когда переходят друг в друга
Мы различаем три разновидности цены.
Первая — фиксированная стоимость. Именно её вы чаще всего видите в Яндекс Такси. Цена рассчитывается на этапе создания офера и, как правило, не меняется до конца поездки. В основе расчёта — маршрут от Яндекс Карт.
Вторая — цена по таксометру. На главном экране она отображается с тильдой или надписью «от». Офер тоже считается по маршруту от Карт, но в процессе и при завершении поездки используются параметры, которые собирал таксометр.
Фиксированная стоимость может превратиться в цену по таксометру. Например, если водитель остановился слишком далеко от точки назначения или точка назначения в процессе поездки менялась слишком много раз.

Третья — интервальная стоимость, разновидность фиксированной. Мы не фиксируем цену точно, а гарантируем, что она попадёт в интервал, с которым пользователь согласился в момент принятия офера, — если, конечно, в заказе не случился переход на таксометр.
Геозоны, тарифы и почему Москва — пригород Рязани
Обычно при заказе такси пользователь указывает две точки — отправления (А) и назначения (Б). Иногда точка Б не указана или точек назначения больше одной, но цена в этих случаях считается похожим образом. Рассмотрим базовый случай.
Что нужно, чтобы посчитать цену?
Во‑первых, тарификация километров и минут. Она живёт в сервисе тарифов. Здесь важно не запутаться в терминах: в Яндекс Такси тарифом мы называем геозону — обычно это город. А то, что все привыкли называть тарифом — «Эконом», «Комфорт», «Комфорт+», — мы называем тарифной категорией.
Во‑вторых, нужно понять, в какой геозоне находится пользователь и в какую он едет. Например, есть две геозоны:
moscow— границы Москвы;moscow_activation— расширенная зона вокруг Москвы.

Если точка подачи попадает в moscow_activation, для расчёта выбирается тариф moscow. Из‑за этого направление влияет на интерпретацию зон. Для маршрута Москва → Рязань Рязань будет считаться пригородом Москвы. Для маршрута Рязань → Москва, наоборот, Москва будет считаться пригородом Рязани.
После того как мы определились с тарифом, мы приступаем к обработке маршрута, каждая точка которого либо лежит в moscow, либо нет. На основании этого мы можем разбить маршрут на интервалы. Например, для маршрута:
[ { "lat": 55.456816, "lon": 37.294845, "dist": 0, "time": 0 }, { "lat": 55.456489, "lon": 37.294612, "dist": 10, "time": 6 }, { "lat": 55.455437, "lon": 37.293983, "dist": 15, "time": 12 }, { "lat": 55.455402, "lon": 37.294226, "dist": 20, "time": 18 }, { "lat": 55.456523, "lon": 37.294895, "dist": 25, "time": 24 }, { "lat": 55.457299, "lon": 37.295352, "dist": 30, "time": 30 } ]
Мы получим разбивку:
[ { "zone":"moscow", "dist": 10, "time": 6 }, { "zone":"suburb", "dist": 15, "time": 18 }, { "zone":"moscow", "dist": 5, "time": 6 } ]
На основании этой разбивки мы сможем построить базовую цену:

География при этом не обязана делиться только на «город» и «пригород». Разбивка может быть, например, «город» — «пригород» — «аэропорт», и для каждой геозоны можно задать свою тарификацию километров и минут.
Кот, лыжи и динамическое ценообразование: что ещё влияет на цену
Однажды разработчик из Сочи решил достать старые лыжи и поехать в Красную Поляну. В это же время у аналитика заболел кот — его срочно нужно везти в ветеринарную клинику. А проджект‑менеджер из Ташкента собрался на велотрек за городом на своём новом скоростном велосипеде. Что общего у этих историй? В каждой из них случайно выбранный водитель может быть не готов везти заказ: аллергия на животных, опасение за салон, просто машина не приспособлена для перевозки велосипеда или лыж. Поэтому нам нужен механизм, который позволит водителю заранее сказать: «Я готов выполнять определённые требования пассажира». Если пассажиру нужна такая опция — она учитывается в цене.

Второй фактор — динамическое ценообразование, или сурдж, как мы его называем. Цена зависит от баланса спроса и предложения в моменте. Подробнее о том, зачем это нужно и как считается, можно почитать в статье наших коллег. Со стороны прайсинга задача одна: корректно применять параметры сурджа к цене.
Шесть требований, которые определили архитектуру
Примеры выше — лишь малая часть сценариев, которые затрагивает расчёт цены. Над Такси работают десятки команд, и каждая время от времени меняет что‑то в механике ценообразования. Из этого вырастают требования к прайсингу:
Правки в алгоритм должны быть простыми. Разработчик, аналитик или менеджер, которому нужно что‑то изменить, должен справиться, зная только свою зону ответственности и не вникая в остальное.
Новая функциональность должна включаться быстро. Если мы объявили, что в 12:00 запускаем новый процесс подачи в аэропорту Шереметьево, — ровно в 12:00 у пользователей должна появиться возможность им воспользоваться.
Каждый расчёт должен быть объяснимым. Это нужно и для отладки новой версии алгоритма, и для разбора обращений пользователей.
Цена должна пересчитываться с небольшими изменениями параметров — как в процессе поездки, так и после неё.
История изменений цены должна храниться. Мы обязаны уметь обосновать каждую цену, которая была рассчитана в заказе.
И наконец, любое изменение должно быстро откатываться. Если в алгоритме допущена ошибка — нельзя ждать часы до следующего деплоя.
Откуда берутся параметры и почему запросы идут параллельно
Параметры ценообразования мы называем BackendVariables. Они описываются в yaml‑файле — по этому описанию плагин кодогенерации строит структуру на C++ и методы для сериализации и десериализации.
... UserData: type: object additionalProperties: false properties: has_yaplus: type: boolean default: false has_cashback_plus: type: boolean default: false selected_loyalty_program: type: string ... BackendVariables: type: object additionalProperties: false required: - country_code2 - tariff - zone - category - user_tags - user_data - surge_params - requirements - category_data - exps properties: paid_supply_params: $ref: '#/definitions/PaidSupplyParams' country_code2: type: string zone: type: string category: type: string user_tags: type: array x-taxi-cpp-type: std::unordered_set items: type: string surge_params: $ref: '#/definitions/SurgeParams' discounts: $ref: '#/definitions/DiscountsInfo' ...
struct UserData { bool operator==(const UserData &other) const = default; bool has_yaplus{}; bool has_cashback_plus{}; ::std::optional<::std::string> selected_loyalty_program{}; }; UserData Parse(const formats::json::Value &elem, formats::parse::To<UserData>); logging::LogHelper &operator<<(logging::LogHelper &lh, const UserData &v); ::formats::json::Value Serialize(const UserData &value, ::formats::serialize::To<::formats::json::Value>); void WriteToStream(const UserData &value, formats::json::StringBuilder &sw, bool hide_brackets = false, const char *hide_field_name = nullptr); void PrintTo(const UserData &obj, std::ostream *os);
Во входных параметрах лежат довольно разнородные объекты: параметры платной подачи, скидок, стоимости ожидания и дополнительных услуг, типа перевозки животного или габаритного груза. Каждый объект — это просто набор чисел и строк, но его вычисление нетривиально. За большинством параметров стоит отдельная команда, которая отвечает за несколько микросервисов, вычисляющих этот параметр.
Отсюда задача: есть несколько сервисов, которые поставляют данные в сервис расчёта цены. Нужно переложить их в BackendVariables.
Решение простое: разные источники данных не зависят друг от друга — значит, можно обрабатывать каждый по отдельности. Интерфейс, который этим занимается, мы назвали процессором. Каждый процессор наследуется от класса AbstractAnyProcessorBase и меняет свои поля BackendVariables, не трогая чужие. Поэтому берём и перекладываем.
template <typename SourceIdT, typename ProcessorIdT> struct AbstractAnyProcessorBase { virtual ~AbstractAnyProcessorBase() = default; virtual ProcessorIdT GetId() const = 0; virtual UpdateContextResult UpdateContext(const AbstractFetchDataContext<SourceIdT>&, const Context&, const ProcessorDeps&) const = 0; };
В методе UpdateContext пользователь определяет способ заполнения полей BackendVariables. Метод обязан возвращать список полей, которые были изменены.
Но откуда процессор берёт данные? Например, для получения скидки нужны данные пользователя, для параметров повышенного спроса — данные по тарифу. Здесь сразу видны три проблемы. Для одного источника данных может понадобиться другой. Последовательно запрашивать каждый источник нельзя — их слишком много. И часть источников некритична: если сервис скидок недоступен, прайсинг должен продолжать работать.
Решение: строим граф зависимостей источников, затем запускаем асинхронные запросы в порядке топологической сортировки. Каждый источник данных наследуется от SourceBase:
template <typename SourceIdT, typename Data, SourceIdT SourceId, bool IsRequired> struct SourceBase : public AbstractSourceBase<SourceIdT> { // Метод для получения данных из источника virtual Data Get(AbstractFetchDataContext<SourceIdT>& ctx, SourceDependenciesBase<SourceIdT> deps) const = 0; // Метод для сохранения данных void UpdateContext(AbstractFetchDataContext<SourceIdT>& ctx, SourceDependenciesBase<SourceIdT> deps) override { if (!value_) { try { value_.emplace(Get(ctx, deps)); } catch (const std::exception& ex) { if constexpr (IsRequired) { throw; } else { LOG_WARNING("Error loading resource {} : {}", ToString(SourceId), ex.what()); value_.emplace(Data{}); } } } } // Метод для получения данных в источнике B от источника A const Data& GetData() const { if (!value_) { throw std::runtime_error(fmt::format("No data in source {}", ToString(SourceId))); } return *value_; } private: std::optional<Data> value_; };
Для каждого процессора известно, какие источники ему нужны:
struct CashbackRatesProcessor : public AbstractProcessor<ProcessorId::kCashbackRatesProcessor> { CashbackRatesProcessor(InitializationContext& init_context) : cashback_rates_source_(init_context.GetSource<CashbackRatesSource>()) {} ... private: const SourcePtr<CashbackRatesSource> cashback_rates_source_; };
Зависимости между источниками строятся через InitializationContext — он же ищет циклические зависимости:
template <typename SourceIdT> struct InitializationContext { private: std::unordered_map<SourceIdT, std::shared_ptr<AbstractSourceBase<SourceIdT>>> created_sources_; std::vector<SourceIdT> currently_initialized_source_; std::unordered_map<SourceIdT, std::unordered_set<SourceIdT>> required_sources_; protected: template <typename T> std::shared_ptr<T> GetSourceImpl() { // Метод проверяет отсутствие циклических зависимостей и возвращает источник данных } public: template <typename T> std::shared_ptr<T> GetSource() { if (currently_initialized_source_.empty()) { throw std::runtime_error("GetSource() available only during initialization"); } required_sources_[currently_initialized_source_.back()].insert(T::source_id); return GetSourceImpl<T>(); } };
Таким образом, для каждого запроса на расчёт цены мы инициализируем список процессоров — например, CashbackRatesProcessor, CouponInfoProcessor, CommonInfoProcessor и другие:
const std::vector<std::shared_ptr<AnyProcessorBase>> processors = { init_context.CreateProcessorBase<backend_variables::CashbackRatesProcessor>(), init_context.CreateProcessorBase<backend_variables::CouponInfoProcessor>(), init_context.CreateProcessorBase<backend_variables::CommonInfoProcessor>(), ... };
Запускаем асинхронные задачи на получение данных из нужных источников:
... source_futures.push_back(utils::Async(ToString(source) + "_load", [source_id = source, &sources, &ctx, &deps]() { try { sources.at(source_id)->UpdateContext(ctx, deps); return source_id; } catch (const std::exception& e) { LOG_ERROR() << "Error during source " + ToString(source_id) + " loading: " + e.what(); throw; } })); ... while (true) { if (auto done_source_idx = engine::WaitAny(source_futures); done_source_idx.has_value()) { ... } engine::current_task::CancellationPoint(); }
Ждём завершения, после — обогащаем BackendVariables:
for (const auto& proc : processors) { const UpdateContextResult& affected_fields_opt = proc->UpdateContext(data_context, proc_context, pdeps); if (!affected_fields_opt) { LOG_DEBUG() << "Processor " << ToString(proc->GetId()) << " not used"; continue; } }
Для асинхронных задач используем фреймворк userver.
Почему алгоритм ценообразования не живёт в коде сервиса
Схема данных меняется редко — добавление нового параметра ценообразования не ломает существующий алгоритм. С самим алгоритмом другая история: в среднем он модифицируется дважды в неделю.
Представим, что код алгоритма живёт прямо в сервисе. А мы почитали комментарии в интернете и всё же решили реализовать ту самую замечательную идею наших пользователей:
if(backend_variables.user_info.is_iphone) { return price * 100; }
Нам важно включить новую функциональность с минимальной задержкой между подами. Но так деплоить — не лучшая идея: сервис pricing-data-preparer развёрнут на пятидесяти подах, выкатка занимает около 40 минут.
Можно обернуть новую функциональность в проверку динамического конфига:
if (config.enable_iphone_pricing) { if(backend_variables.user_info.is_iphone) { return price * 100; } }
Но и здесь есть проблема. Алгоритм меняется часто — код быстро превратится в неподдерживаемое месиво из if’ов.
Теперь представим: A/B‑тест показал, что фича понравилась пользователям. Мы решили оставить её навсегда и удалили enable_iphone_pricing из кода. Выкатили сервис — и выяснили, что A/B‑тест был проведён некорректно и функциональность на самом деле не зашла. Нужно откатываться. Но в ту же ревизию попали правки алгоритма для пассажиров с собакой‑поводырём — откатиться на предыдущую никак нельзя. На хотфикс, проверку в CI, билд тяжёлого сервиса и выкатку уйдут часы.
И есть ещё одна проблема. Наши сервисы живут в огромной экосистеме: логи, метрики, асинхронный фреймворк, библиотеки для обращений к другим сервисам. Над всем этим работают сотни разработчиков Яндекса, поэтому деплоить сервисы стоит как можно реже.
Так что же делать? Ответ: не писать код алгоритма ценообразования в коде сервиса.
Язык преобразований цены
Итак, код алгоритма не живёт в сервисе. Где же тогда? В админке Такси.

Алгоритм ценообразования должен быть простым — C++ здесь не подойдёт. Можно было взять скриптовый язык — JavaScript или Lua, — но мы пошли другим путём... Мы сделали ещё один DSL.
Грамматика простая и привычная для большинства разработчиков — и не только. Связывание выражения с именем — let x = expression;. Изменить связь нельзя. В этом смысле в языке нет переменных — только константы. Управление потоком — if и тернарный оператор. Для работы с optional‑значениями используется конструкция as:
if (optional_value as value) { // if optional_value is not empty, use `value` as alias for *optional_value } else { // if optional_value is empty }
let x = (optional_field as field) ? field : default;
Язык поддерживает pack именованных значений (он же NamedTuple):
let pack = {x: 1, y: "str"}; let x = pack.x;
Поддерживаются пользовательские функции. Каждая возвращает NamedTuple:
function foo(x: double, y: double) { return {x_1 = x, y_1 = y}; } let x = foo(x = 9, y = 1).x_1; // x == 9
Циклов нет — намеренно. Вместо них конструкция fold:
function foo(elem: double, sum: double) { return {sum = sum + elem}; } let result = fold(container as elem, foo, {sum = 0});
В коде на C++ программа на нашем языке представлена классом Program:
class Program { public: ... pricing_platform::lang::models::CalcResult Calculate( const lang::variables::BackendVariables& fix, const lang::variables::DynamicContext& ride, const lang::variables::TripDetails& trip, const lang::variables::EvaluatingContext& context, const lang::variables::Meta& metadata, const ::pricing_platform::lang::models::FeaturesSet& features, const bool enable_debug = false ) const; };
Использовать в коде можно примерно таким образом:
const auto program = parser.Parse(source); const auto result = program.Calculate(fix,ride, trip, context,metadata, features, false);
Для описания грамматики используем ANTLR 4. Язык достаточно простой, и у нас есть его полная спецификация — это позволяет валидировать код, написанный на нём. Для этого используем формальный верификатор Z3 — он проверяет, что программа на нашем языке не может привести к некорректному результату.
Как преобразования выстраиваются в цепочку
При проектировании мы исходили из того, что каждая команда отвечает за свою зону ответственности, — и в части данных, и в части алгоритма. Алгоритм ценообразования представляет собой цепочку преобразований цены, которые применяются последовательно друг за другом.

Каждое преобразование — это чистая функция от базовой стоимости, BackendVariables и служебных параметров, которая возвращает цену и метаданные. Таким образом, алгоритм ценообразования — это композиция преобразований цены.
Продуктовых сценариев может быть много — от обычного заказа такси до совместных поездок. Для каждого можно соорудить свою цепочку.
Как DSL и сервис работают вместе
Итак, у нас есть данные на C++ и код на нашем языке — нужно научить преобразования цены взаимодействовать с данными.
Сначала задаём схему: что входит в контекст вычислений и что возвращает каждое преобразование. calculation_parameters — имена переменных, доступных в любом преобразовании, calculation_result — то, что каждое преобразование возвращает.
calculators: - name: taxi-pricing space_name: taxi schema_file: backend_variables.yaml calculation_parameters: - variable_name: fix type_name: BackendVariables - variable_name: ride type_name: DynamicContext inner_variables: - variable_name: ride type_name: TaximeterVariables - variable_name: price type_name: Price - variable_name: trip type_name: TripDetails - variable_name: context type_name: EvaluatingContext - variable_name: metadata type_name: Meta calculation_result: price: variable_name: price type_name: Price additional_results: - variable_name: metadata type_name: Meta
В schema_file описываем схему данных, с которой работают преобразования: тип каждой входной переменной задаётся явно. Тогда в коде вида:
if (fix.category == "econom") { ... }
fix — значение уже знакомого нам типа BackendVariables.
Но здесь есть проблема: BackendVariables — это структура на C++, а выражение fix.category — конструкция нашего языка. Чтобы программа работала, нужно связать имя поля с членом BackendVariables. Встроенной рефлексии «перечисли поля по имени» в C++ нет — поэтому мы используем Boost.Fusion.
Библиотека позволяет представить структуру как кортеж и обращаться к полям по индексу. При помощи кодогенерации для каждой структуры генерируются макросы вида:
BOOST_FUSION_ADAPT_STRUCT(handlers::libraries::pricing_functions::BackendVariables, (std::optional<::handlers::libraries::pricing_functions::PaidSupplyParams>, paid_supply_params) (std::string, country_code2) (std::string, zone) (std::string, category) ...
Дальше строим отображение типов C++ → типы языка прайсера: для каждой адаптированной структуры регистрируем описание полей.
const TypeMapping& FillMapping<pricing_functions::taxi_pricing::lang::models::PricerData::PricerLabel>() { ... TypeMappingMaker::MakeTypeMapping<lang::variables::BackendVariables>(); TypeMappingMaker::MakeTypeMapping<lang::variables::DynamicContext>(); ... return TypeMappingMaker::MappingInstance(); }
По Fusion‑последовательности собирается список полей:
template <typename T, typename = std::make_index_sequence_t<boost::fusion::result_of::size<T>::type::value>> struct ListFields; template <typename T, size_t... idx> struct ListFields<T, std::index_sequence<idx...>> { static std::vector<types::ValueField> List() { return std::vector<types::ValueField>({MapField<T, idx>()...}); } }; template <typename T, typename = void> struct TypeMapper; template <typename T> struct TypeMapper< T, std::enable_if_t<boost::fusion::traits::is_sequence<T>::value>> { static auto Map() { return std::make_unique<types::BindedStruct>(details::GetTypeWithName<T>(), ListFields<T>::List()); } }; template <typename T> static const types::BaseType& TypeMappingMaker::MakeTypeMapping() { auto& mapping = MappingInstance(); if (const auto it = mapping.find(typeid(T)); it != mapping.end()) { return *it->second; } const auto& result = *mapping.emplace(typeid(T), TypeMapper<T>::Map()).first->second; if constexpr (!std::is_same_v<T, AddOptionalT<T>>) { MakeTypeMapping<AddOptionalT<T>>(); } return result; }
В момент парсинга программы мы понимаем, что фрагмент вида fix.paid_supply_params — это обращение к полю структуры fix. Тогда мы можем сделать две вещи: проверить, что у fix действительно есть поле paid_supply_params, — если нет, программа не распарсится. И если поле есть — по std::type_index взять из мапы типов описание, зарегистрированное в MakeTypeMapping, и связать поле с типом в языке преобразований цены.
const types::BaseType& GetTypeMapping(std::type_index type) { static const auto& type_mapping = FillMapping(); if (const auto it = type_mapping.find(type); it != type_mapping.end()) { return *it->second; } throw std::domain_error("Attempt to use non-adapted type `" + compiler::GetTypeName(type) + "`"); } explicit Type(const std::type_index& type): type_(types::GetTypeMapping(type)) {}
Что получилось и куда это пошло дальше
Мы вынесли правила ценообразования в отдельный слой между сервисом на C++ и бизнес‑логикой расчёта. Теперь типовое изменение алгоритма не требует пересборки и деплоя сервисов прайсинга: вместо выкатки сервиса, которая занимала около 40 минут, применяется новая версия цепочки преобразований.
Что изменилось:
алгоритм, который меняется в среднем два раза в неделю, теперь обновляется без деплоя сервиса;
откат правки занимает время применения версии алгоритма, а не полный цикл хотфикса;
каждый расчёт стал воспроизводимым: сохраняются входные параметры, цепочка преобразований и промежуточные результаты.
Проект по выносу алгоритма ценообразования из кода сервиса оказался удачным — мы платформизировали наработки и предложили их другим командам. Сейчас платформу прайсинга используют в Такси, Доставке и зарядках для электромобилей.
Платформизация также помогла расставить акценты: разработка отвечает за техническую составляющую, а аналитики прайсинга стали основными мейнтейнерами алгоритмов ценообразования. Для этого была выделена отдельная небольшая команда, ответственная исключительно за платформу.
Главный вывод: часто меняющийся алгоритм, за который отвечают разные команды, лучше хранить не в коде сервиса, а как проверяемую и версионируемую цепочку преобразований.