Rate channel selection per brand and currency
This document describes an approach to extend in Ebury Core the current selection of a rate channel for a trade so that it also takes into account the brand of the client running the request. This is a requirement for the integration of Ebury Mass Payments into the main platform.
Problem Description
Ebury Mass Payments (EMP) is focused on payrolls. When a payment has to be made, they have some timing constraints because they need the money to be delivered at the payroll date. In order to achieve this, they already know which Liquidity Providers (LPs) to use for specific currencies, since they give them the timing guarantees they need.
As part of EMP integration into Ebury Core, a new functionality is required in the latter in order to support the aforementioned scenario. In particular, EMP needs to specify the rate channel to use for any given currency. In this way, they can choose Xignite as the rate channel whenever they need control on the LP to use for the currencies being requested. Afterwards, the EMP team can buy the appropriate instrument in the corresponding trading platform of the LP, as they already do.
Since EMP is going to be integrated into Ebury Core as a brand, the problem above can be generalized as: "brands must be able to select their preferred rate channel for any given product and currency pair".
In other words, the goal of this document is to design a solution that allows us to efficiently
provide a functionf(brand, product, buy_currency, sell_currency) = r, being r the rate channel
to use given the referred inputs. Given the priority of the EMP migration, we must be careful about
the effort required to release the proposed implementation in production.
Background
The functionality to select a rate channel for a given quote is currently embedded within FXS.

The existing implementation uses some terms that don't match 100% with the domain terminology we employ nowadays. Hence, in the table below you can find a handy reference to the mapping between terms in code vs our current business language, so that you can more easily translate the models we'll see in this section with their actual meaning.
| Code term | Domain term |
|---|---|
| Partner + FxPartner | Brand |
| Liquidity Provider | Rate Channel |
| Venue | Liquidity Provider |
| Product | Product |
The figure below shows a partial entity-relationship diagram of our current data model in FXS. As it is right now, it doesn't allow to map brands to specific rate channels. Therefore, we'll propose some extensions in the sections below.

Using this data model, we're currently implementing a function
f(brand, product, buy_currency, sell_currency) = r that works as in the following:
1. Check buy/sell currencies are included in the list of buy/sell currencies allowed by a
particular product and brand (this is configured in the brand itself -
FxPartner/FxPartnerProduct models) [NOTE: not checked for swaps].
2. If same buy/sell currency, rate channel "Ebury Same Currency" is active, rate channel is enabled
for brand, and currency is supported by rate channel: return "Ebury Same Currency" [NOTE: not
for swaps].
3. If buy/sell currencies are supported by smartTrade, rate channel "smartTrade" is active, and
rate channel is enabled for brand: return "smartTrade".
4. If buy/sell currencies are supported by Barx, rate channel "Barx" is active, and rate channel
is enabled for brand: return "Barx".
5. If buy/sell currencies are supported by Xignite, rate channel "Xignite" is active, and rate
channel is enabled for brand: return "Xignite" [NOTE: not for swaps].
The current model has some shortcomings:
-
The existing complexity might lead to inconsistent data states. E.g. a given product might be defined for a set of currencies which are not supported by the rate channel which is meant to be used. The data model per se doesn't prevent these kinds of inconsistencies (code does).
-
Ebury prefers some rate channels over the others. In particular, smartTrade > Barx > Xignite (leaving aside "Ebury Same Currency", used when buy and sell currencies are the same one). This preference is forced across the code base, but it could be given by the data model itself.
Below you'll find options to add the functionality of choosing the rate channel on a per-brand basis, possibly fixing some of these existing limitations.
Solution
Strategic (long-term)
In the Ebury 2.0 architecture, the rate channel selection functionality should be owned by an explicit Quote Service. The different Channels in Ebury could interact with it through a REST API. In particular, Channels would request quotes, the Quote Service would determine the appropriate rate channel in every case, and then it would direct a request towards the corresponding Gateway.

Once a Gateway publishes the quote, the Quote Service consumes that message, it creates the quote in its database, and it publishes a domain event to signal that a new quote has been created. The REST module of the Quote Service is able to provide the Channel with a response at this time.

Note that in the figures above, we're using a synchronous REST API as an example. However, it doesn't have to be implemented in that way. The Channels could obtain the quote by asynchronous means, e.g. using polling, webhooks, or web sockets.
In addition to the former API, the Quote Service will also expose management endpoints to configure the rate selection process for the different brands, products, and currencies. Such an API would be consumed by a management Channel - the Operations UI - in order to provide the Operations team with the tools to manage rate channels on their own.
This architecture is provided here only as a reference, since taking the whole quoting functionality out of FXS is not within the scope of this document. However, we propose here to bootstrap the development of the Quote Service by implementing the Rate Channel Selection module. At the very least, we'll start paying off the technical debt introduced in the tactical solution below. In addition, we might remove the Rate Channel Selection logic from FXS and reuse the one developed here as an interim step if it's considered appropriate.
Interface
The figure below shows the minimum required API for the Rate Channel Selection module in order to outsource this functionality within the Quote Service. Note however that the endpoint for FXS to retrieve the appropriate channel is optional, since it only makes sense if such a migration path towards Ebury 2.0 is taken. Alternatively, other approaches might be more effective, like strangling the quoting functionality out of FXS on a per rate-channel basis (i.e. BOS calls the Quote Service, which directly provides the quote for some rate channels, but still relies on FXS for the ones not migrated yet).

On the other hand, the management interface is needed regardless the migration path taken in the future. It requires at least the following endpoints, but this list might change depending on the UX for the Operations UI (e.g. requiring bulk operations, PATCH methods, etc).
-
GET /rate_channel_routes. It retrieves the collection of entries of theRateChannelRouterdescribed below. Filters may be applied (brand, product, rate channels, or currencies). Each entry embeds the referred objects, or otherwise additional endpoints are required. The response is paginated. -
POST /rate_channel_routes. It adds a new entry in theRateChannelRouter. -
DELETE /rate_channel_routes/:id. It removes an entry from theRateChannelRouter.
Note that in order to implement the aforementioned endpoints, we need an authentication and authorization mechanism in place. It'd be ideal to get to a common solution across all Ebury microservices instead of having to propose and implement one for every new component.
Data model
We propose the following clean slate model to be implemented within the Rate Channel Selection module.

Basically, the brand would have a set of rules, specified in the RateChannelRouter model, to
choose their preferred rate channel for a given product and currencies. A possible implementation
could look like the following (not declaring field constraints for readability reasons):
from django.db import models
class Currency(models.Model):
symbol = models.CharField()
# ...
class Product(models.Model):
code = models.IntegerField()
name = models.CharField()
class RateChannel(models.Model):
name = models.CharField()
rank = models.IntegerField()
active = models.BooleanField()
class Brand(models.Model):
rate_channels = models.ManyToManyField(RateChannel, through='RateChannelRouter')
name = models.CharField()
# ...
class RateChannelRouter(models.Model):
rate_channel = models.ForeignKey(Brand, on_delete=models.CASCADE)
brand = models.ForeignKey(Brand, on_delete=models.CASCADE)
buy_currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
sell_currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
def get_rate_channels(brand_id, product_id, buy_symbol, sell_symbol):
brand = Brand.objects.get(id=brand_id)
qs = brand.rate_channels.filter(
product__id=product_id,
buy_currency__symbol=buy_symbol,
sell_currency__symbol=sell_symbol
)
rate_channels = qs.filter(active=True).order_by('rank').all()
return rate_channels
The sample code above is assuming the Django ORM, but the Quote Service won't use that framework. However, this code is here only for illustrative purposes to give an idea of the implementation.
This data model exhibits some good properties:
-
The implementation of
f(brand, product, buy_currency, sell_currency)is fast and straightforward. In addition, it doesn't require handling swaps or "same-currencies" as special cases. -
The database model is simple. All the config required for any brand/product is centralized in a single table, also improving the maintainability of the code base.
-
As a corollary, the data model prevents potential inconsistencies across the currencies supported by brands, products, and rate channels.
-
Using one rate channel over the others is now a matter of updating the
rankfield of theRateChanneltable. I.e. the code doesn't have to explicitly know the priority of every channel (as it happens now) since it's given by the data model.
On the other hand, it depends on data that currently exists in the FXS database. Therefore, a data migration strategy should be devised. The decision on how to do this is postponed since it only makes sense once we're going to enable the new component in production, but for that we must first know whether we're going to outsource the Rate Channel Selection or strangle the FXS quoting functionality. In any case, we'll need either one-off incremental migrations (e.g. outside market hours) or a live update using Kafka events.
Tactical (short-term)
The strategic solution above has been designed to pay off the technical debt introduced by the tactical solution proposed in this section. Obviously, taking the quoting functionality out from FXS is not required to fulfill the requirements of the EMP integration within Ebury Core, and it'd require a significant effort that would delay such an integration.
Interface
Because of the former, now we propose minimal changes to the current solution in order to meet EMP expectations. As explained above, right now we first check whether the product supports the buy/sell currencies; then we look for the active rate channels that the brand can use for the requested currencies.
In this approach, we keep the existing interfaces and just augment them to relate brands and rate channels through currencies.
Data model
Our proposal consists of relating brands and rate channels for a given currency by means of a
RateChannelRouter model.

The main benefit of this approach is that the implementation of
f(brand, product, buy_currency, sell_currency) is akin to the existing one, therefore requiring
less development effort.
On the other hand, this approach doesn't fix the shortcomings of the existing model. In particular, the data model itself doesn't prevent potential inconsistencies across the currencies supported by brands/products and rate channels.
The main impact of this approach is that FxPartner is now related to LiquidityProvider via the
new RateChannelRouter model instead of the existing many-to-many relationship. The existing UI
views that use this model, don't offer the opportunity of choosing the product or currencies for
this rate channel. Some UX/UI design effort is needed in order to support the main use cases of the
Support team. Will we want to easily update the currencies supported for any given rate channel?
Only for a rate channel and brand? Rate channel, brand, and product? All the former? In any case,
we'll have to have this conversation with the Support team and update or substitute the current UI.


The code paths for getting rates and quotes for all products, including swaps, must be updated. Also, we need to plan carefully the data migration required to avoid outages and still allow for zero-downtime deployments.
Finally, Support scripts will have to be revisited to include the new functionality.
Alternatives
Trade-offs in the clean slate model
The clean-slate model shown for the strategic solution is optimized for the function we're
addressing in this document. As such, it makes some trade-offs, e.g. the RateChannelRouter table
has a large cardinality. If that were an issue, and provided some assumptions hold true, we could
reduce it significantly at the expense of additional database queries:
class RateChannelRouter(models.Model):
rate_channel = models.ForeignKey(Brand, on_delete=models.CASCADE)
brand = models.ForeignKey(Brand, on_delete=models.CASCADE)
currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
def get_rate_channels(brand_id, product_id, buy_symbol, sell_symbol):
brand = Brand.objects.get(id=brand_id)
buy_qs = brand.rate_channels.filter(product__id=product_id, currency__symbol=buy_symbol)
sell_qs = brand.rate_channels.filter(product__id=product_id, currency__symbol=sell_symbol)
rate_channels = (buy_qs & sell_qs).filter(active=True).order_by('rank').all()
return rate_channels
Clean slate model in FXS
Instead of implementing the data model for the strategic solution in a separate service, we could go for it straight on FXS. However, in addition to the effort described in the tactical solution, we'd also need to address additional significant changes:
Substituting Partner/FxPartner for Brand requires lots of changes in UI and tests, in
addition to significant refactors and data migrations. In fact, these models are linked to most
main models in FXS.
Similarly, substituting LiquidityProvider for RateChannel is cumbersome for the amount of
relationships the existing model has. E.g. Quote (spots, forwards, and multipayments) and
FIXQuote (swaps) models have to be updated to link to a RateChannel instead of a
LiquidityProvider. We have to be careful with the data migration, since these tables have a large
amount of rows. Alternatively, we could leave existing quotes linked to the old model, and new ones
to the new model. However, this would keep some current complexity in the data model, and it will
make the code a bit more intricate (it has to support two different cases).
FxPartnerProduct is not needed anymore. This implies updating the implementation of
FxPartner.currencies_in_product(), view_product_for_fxpartner(), and get_quote_timeout().
Product loses its references to LiquidityProvider and Currency (buy/sell). Instead, all
this information must be retrieved from the RateChannelRouter. Also, in order to apply the
algorithms above, "swaps" should be defined as another product category, which is not the case
right now: only "spot", "forward", and "multipayment" are defined.
Finally, we have to extend the Currency model with an is_cover attribute and run the
appropriate data migrations. All client code and tests must be updated accordingly.
As you can see, all this means a huge effort on a legacy service, so this option is fully discarded.
Partial model upgrade in FXS
Instead of implementing the full clean slate approach right into FXS, we might pick just the one bit that provides the highest value in terms of code maintainability and performance.
In particular, we'd only introduce the RateChannelRouter and then remove the existing models and
relationships which get deprecated because of this.

In such a case, the implementation of f(brand, product, buy_currency, sell_currency) is faster
than the existing one. In addition, it doesn't require handling swaps or "same-currencies" as
special cases.
The database model is also simpler than the existing one. All the config required for any brand/product is centralized in a single table, also improving the maintainability of the code base and preventing potential inconsistencies across the currencies supported by brands and products.
However, the data model doesn't prevent potential inconsistencies across the currencies supported by brands/products and rate channels. And there are still lots of changes required in the current code base:
- Introduce the new
RateChannelRoutermodel. - Get rid of
FxPartnerProduct. - Remove relationships from
Product.
This, along with the additional data migrations introduced, makes us discourage the implementation of such an option.
Caveats
N/A
Operation
For the tactical solution, the Support team will be still in charge of managing the configuration of brands, products, rate channels, and currencies as they're doing today via the FXS UI and their own scripts.
In the long-term strategic solution, the Operations UI will be used by the proper Operations teams in each case.
Security Impact
None for the tactical solution.
For the strategic solution, at some point we'll need a common authn/authz framework across Ebury that can be used by our different services.
Performance Impact
In the tactical solution, the performance impact should be negligible.
In the strategic approach, there's a performance improvement because of fewer and more efficient database queries are required.
Developer Impact
N/A
Data Contracts
In the tactical solution, we're changing the existing data model in FXS. The code impacted mostly lies within FXS, and therefore it must be amended as part of the implementation of this proposal. Similarly, existing Support scripts to manage brands/rate channels might need an update. No other external parties get affected by this change.
Data Sources
In order to enable the strategic solution in production, we must first define the migration path we want to follow. If there are live updates from FXS to the Quote Service, then the following transformations would have be done:
Partner+FxPartner-->BrandLiquidityProvider-->RateChannelProduct-->ProductCurrency+LiquidityProviderCurrency-->CurrencyRateChannelRouter+LiquidityProviderCurrency+FxPartnerProduct-->RateChannelRouter
However, please note that determining the migration path is not within the scope of this document.
Deployment
The data migrations required for the tactical solution will have to be released in stages. A spike will be created to design a rollout that prevents service disruption and allows for zero-downtime deployments.
Dependencies
N/A
References
[1] Ticket requesting a high-level effort estimation for selecting rate channels based on brands.
[2] Estimation (output of former ticket).
[3] Ebury 2.0 blueprint.
[4] smartTrade Internal Book blueprint.