Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

currency module #1374

Merged
merged 20 commits into from
Aug 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions modules/currency.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import bidfactory from 'src/bidfactory';
import { STATUS } from 'src/constants';
import { ajax } from 'src/ajax';
import * as utils from 'src/utils';
import bidmanager from 'src/bidmanager';
import { config } from 'src/config';

const DEFAULT_CURRENCY_RATE_URL = 'http://currency.prebid.org/latest.json';
const CURRENCY_RATE_PRECISION = 4;

var bidResponseQueue = [];
var conversionCache = {};
var currencyRatesLoaded = false;
var adServerCurrency = 'USD';

// Used as reference to the original bidmanager.addBidResponse
var originalBidResponse;

export var currencySupportEnabled = false;
export var currencyRates = {};
var bidderCurrencyDefault = {};

/**
* Configuration function for currency
* @param {string} [config.adServerCurrency = 'USD']
* ISO 4217 3-letter currency code that represents the target currency. (e.g. 'EUR'). If this value is present,
* the currency conversion feature is activated.
* @param {number} [config.granularityMultiplier = 1]
* A decimal value representing how mcuh to scale the price granularity calculations.
* @param {object} config.bidderCurrencyDefault
* An optional argument to specify bid currencies for bid adapters. This option is provided for the transitional phase
* before every bid adapter will specify its own bid currency. If the adapter specifies a bid currency, this value is
* ignored for that bidder.
*
* example:
* {
* rubicon: 'USD'
* }
* @param {string} [config.conversionRateFile = 'http://currency.prebid.org/latest.json']
* Optional path to a file containing currency conversion data. Prebid.org hosts a file that is used as the default,
* if not specified.
* @param {object} [config.rates]
* This optional argument allows you to specify the rates with a JSON object, subverting the need for a external
* config.conversionRateFile parameter. If this argument is specified, the conversion rate file will not be loaded.
*
* example:
* {
* 'GBP': { 'CNY': 8.8282, 'JPY': 141.7, 'USD': 1.2824 },
* 'USD': { 'CNY': 6.8842, 'GBP': 0.7798, 'JPY': 110.49 }
* }
*/
export function setConfig(config) {
let url = DEFAULT_CURRENCY_RATE_URL;

if (typeof config.rates === 'object') {
currencyRates.conversions = config.rates;
currencyRatesLoaded = true;
}

if (typeof config.adServerCurrency === 'string') {
utils.logInfo('enabling currency support', arguments);

adServerCurrency = config.adServerCurrency;
if (config.conversionRateFile) {
utils.logInfo('currency using override conversionRateFile:', config.conversionRateFile);
url = config.conversionRateFile;
}
initCurrency(url);
} else {
// currency support is disabled, setting defaults
utils.logInfo('disabling currency support');
resetCurrency();
}
if (typeof config.bidderCurrencyDefault === 'object') {
bidderCurrencyDefault = config.bidderCurrencyDefault;
}
}
config.getConfig('currency', config => setConfig(config.currency));

function initCurrency(url) {
conversionCache = {};
currencySupportEnabled = true;

if (!originalBidResponse) {
utils.logInfo('Installing addBidResponse decorator for currency module', arguments);

originalBidResponse = bidmanager.addBidResponse;
bidmanager.addBidResponse = addBidResponseDecorator(bidmanager.addBidResponse);
Copy link
Contributor

@dbemiller dbemiller Aug 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking over the issue @snapwich brought up here: I don't think we should support modules which monkey-patch functions in core.

Reason being: I've heard that we expect other people to write their own external modules. If so, then this behavior means that all of Prebid's internal APIs are now external APIs. If we remove a method or change a signature on anything we export, they could reasonably claim that it's a breaking change to the library.

I propose that we deliberately define all the "plugin points" in prebid-core, which are functions which we expect modules to call. For example, registerBidAdapter() is a plugin point today.

@snapwich @mkendall07 What do you guys think?

Off the top of my head, something like addBidPostprocessor((bid) => bid)) looks like a reasonable "plugin point" for this feature--although I'm not sure it fits with the spirit of the project. I just think that whatever it is, it should be explicit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we remove a method or change a signature on anything we export, they could reasonably claim that it's a breaking change to the library.

That's true but the same would be true if we created a plugin system. I do think creating a standardized way to implement a plugin to Prebid.js core would be nice though; although a big undertaking, so I wonder if we should wait until we have a few more use cases other than currency?

In the current state I'm not sure how we could do this without this decorator. I considered using the event system, but that would require mutating the bid objects, which seemed hacky; plus I don't think there would be any way using events to defer the bid response (which is required if currency file hasn't loaded yet).

If there's a better way to do the currently provided functionality I'm open to hearing it. Otherwise, maybe we could put a plugin system on the backlog for the future when we have more modules that require such features and we have a more formal insight into their needs?

It's possible the need for that plugin system is now though if we can't figure out a solution for this module and concurrent auctions.

Copy link
Contributor

@dbemiller dbemiller Aug 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah... I agree, a standardized way to implement plugins is way outside of the scope. I'm not even sure it's possible... but if it is, we definitely need more use-cases.

My suggestion would be something like this (not sure if you need sync or async functions... so I described both):

bidAdjustmentsRegistry.js:

function newBidAdjustmentRegistry() {
  const adjustments = []
  return {
    // adjuster should be a function of the type (bid, callback) => void
    register(adjuster) {
      adjustments.push(adjuster);
    },
    adjust(bid, callback) {
      // execute each function in the queue, and do the callback on the result. Execute callback(bid) the input if no adjustments exist
    }
  }
}

export const bidAdjustments = newBidAdjustmentRegistry();

In the currency module, replace bidmanager.addBidResponse = addBidResponseDecorator(bidmanager.addBidResponse); with an equivalent bidAdjustments.register(impl). I think this is doable, but correct me if I'm wrong.

In #1421, @jaiminpanchal27 can call bidAdjustments.adjust(bid) inside the addBidResponse function. Or bidAdjustments.adjust(bid, addBidResponse) if it's async.

Does that sound like it'd work?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi. I'm relatively new to the project and have followed this discussion with a lot of interest.
We are currently facing the issue of converting currencies as well and this what we are planning to do,
which might be of interest as well.

Use bidderSettings

At this point using the the pbjs.bidderSettings to update our CPMs. If the currency module would simply be a library to call then integrating this would be a breeze. Our code looks something like this:

export const bidderSettings: prebidjs.IBidderSettings = {
  criteo: {
    bidCpmAdjustment: (bidCpm, bid) => {
       // do the currency calculation here
       return bidCpm;
    }
  },
  appnexusAst: {
    bidCpmAdjustment: (bidCpm, bid) => {
       // do the currency calculation here
       return bidCpm;
    }
  }
};

If the currency module can just be imported like

pbjs.currency.init(); // or even with a setting

const cpmInEuro = pbjs.currency.convert(cpm, 'USD', 'EUR');

General approach for bid responses

Regarding a general plugin architecture. We have had very good experience with redux middlewares.
Basically you they are functions you can chain together. A BidResponseMiddleware would be a nice
way to abstract over all the stuff that is currently configured somewhere and applied somehow.

Also a middleware could do all the side-effects, which makes these things trivial to test, because
you can just swap them.

Example

A middleware could have the shape of

(bidResponse: BidResponse) => (bidResponse: BidResponse)

By definition a middleware applies some side effects and calls the next
middleware. A middleware can also change the bidResponse, e.g. with currency calculations
or other stuff.

// apply takes a list of middlewares and chains them
const defaultMiddlewares = apply(
   new AdServerTargetingMiddleware(pbjs.bidderSettings),
   new BidCpmAdjustmentMiddleware(pbjs.bidderSettings),
   new MyCustomMiddleware(),
);

It would also be nice to have access to the raw response from the actual SSP to work around
bugs or missing implementations. What do you think?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the ideas @muuki88, but we've decided to move forward with the design as noted in #1089, modified slightly to use the new setConfig() approach. We think this has the advantage of preserving the bidCpmAdjustment for bidder-specific gross-vs-net conversions, separating currency which really isn't bidder-specific.

As for debugging, @snapwich has already implemented what we think is a pretty solid approach -- the bidResponse gets two new attributes: originalCpm and originalCurrency. Then cpm and currency are overwritten with the converted values. So you'll be able to access the before/after bidResponse attributes from the console.

}

if (!currencyRates.conversions) {
ajax(url, function (response) {
try {
currencyRates = JSON.parse(response);
utils.logInfo('currencyRates set to ' + JSON.stringify(currencyRates));
currencyRatesLoaded = true;
processBidResponseQueue();
} catch (e) {
utils.logError('failed to parse currencyRates response: ' + response);
}
});
}
}

function resetCurrency() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A reset() function (almost?) never makes sense. Any caller who wants to do:

var thing = newThing()
...
thing.reset()

can always do:

var thing = newThing()
...
thing = newThing()

instead.

They're functionally equivalent, but the latter is conceptually simpler. It's also easier to test.

To rigorously test an object with a reset(), you've effectively gotta run all your tests twice--to make sure all the state was reset properly.

To rigorously test an object with a new, you only need to run them once.

if (originalBidResponse) {
utils.logInfo('Uninstalling addBidResponse decorator for currency module', arguments);

bidmanager.addBidResponse = originalBidResponse;
originalBidResponse = undefined;
}

adServerCurrency = 'USD';
conversionCache = {};
currencySupportEnabled = false;
currencyRatesLoaded = false;
currencyRates = {};
bidderCurrencyDefault = {};
}

export function addBidResponseDecorator(fn) {
return function(adUnitCode, bid) {
if (!bid) {
return fn.apply(this, arguments); // if no bid, call original and let it display warnings
}

let bidder = bid.bidderCode || bid.bidder;
if (bidderCurrencyDefault[bidder]) {
let currencyDefault = bidderCurrencyDefault[bidder];
if (bid.currency && currencyDefault !== bid.currency) {
utils.logWarn(`Currency default '${bidder}: ${currencyDefault}' ignored. adapter specified '${bid.currency}'`);
} else {
bid.currency = currencyDefault;
}
}

// default to USD if currency not set
if (!bid.currency) {
utils.logWarn('Currency not specified on bid. Defaulted to "USD"');
bid.currency = 'USD';
}

// execute immediately if the bid is already in the desired currency
if (bid.currency === adServerCurrency) {
return fn.apply(this, arguments);
}

bidResponseQueue.push(wrapFunction(fn, this, arguments));
if (!currencySupportEnabled || currencyRatesLoaded) {
processBidResponseQueue();
}
}
}

function processBidResponseQueue() {
while (bidResponseQueue.length > 0) {
(bidResponseQueue.shift())();
}
}

function wrapFunction(fn, context, params) {
return function() {
var bid = params[1];
if (bid !== undefined && 'currency' in bid && 'cpm' in bid) {
var fromCurrency = bid.currency;
try {
var conversion = getCurrencyConversion(fromCurrency);
bid.originalCpm = bid.cpm;
bid.originalCurrency = bid.currency;
if (conversion !== 1) {
bid.cpm = (parseFloat(bid.cpm) * conversion).toFixed(4);
bid.currency = adServerCurrency;
}
} catch (e) {
utils.logWarn('Returning NO_BID, getCurrencyConversion threw error: ', e);
params[1] = bidfactory.createBid(STATUS.NO_BID, {
bidder: bid.bidderCode || bid.bidder,
bidId: bid.adId
});
}
}
return fn.apply(context, params);
};
}

function getCurrencyConversion(fromCurrency) {
var conversionRate = null;
var rates;

if (fromCurrency in conversionCache) {
conversionRate = conversionCache[fromCurrency];
utils.logMessage('Using conversionCache value ' + conversionRate + ' for fromCurrency ' + fromCurrency);
} else if (currencySupportEnabled === false) {
if (fromCurrency === 'USD') {
conversionRate = 1;
} else {
throw new Error('Prebid currency support has not been enabled and fromCurrency is not USD');
}
} else if (fromCurrency === adServerCurrency) {
conversionRate = 1;
} else {
var toCurrency = adServerCurrency;

if (fromCurrency in currencyRates.conversions) {
// using direct conversion rate from fromCurrency to toCurrency
rates = currencyRates.conversions[fromCurrency];
if (!(toCurrency in rates)) {
// bid should fail, currency is not supported
throw new Error('Specified adServerCurrency in config \'' + toCurrency + '\' not found in the currency rates file');
}
conversionRate = rates[toCurrency];
utils.logInfo('getCurrencyConversion using direct ' + fromCurrency + ' to ' + toCurrency + ' conversionRate ' + conversionRate);
} else if (toCurrency in currencyRates.conversions) {
// using reciprocal of conversion rate from toCurrency to fromCurrency
rates = currencyRates.conversions[toCurrency];
if (!(fromCurrency in rates)) {
// bid should fail, currency is not supported
throw new Error('Specified fromCurrency \'' + fromCurrency + '\' not found in the currency rates file');
}
conversionRate = roundFloat(1 / rates[fromCurrency], CURRENCY_RATE_PRECISION);
utils.logInfo('getCurrencyConversion using reciprocal ' + fromCurrency + ' to ' + toCurrency + ' conversionRate ' + conversionRate);
} else {
// first defined currency base used as intermediary
var anyBaseCurrency = Object.keys(currencyRates.conversions)[0];

if (!(fromCurrency in currencyRates.conversions[anyBaseCurrency])) {
// bid should fail, currency is not supported
throw new Error('Specified fromCurrency \'' + fromCurrency + '\' not found in the currency rates file');
}
var toIntermediateConversionRate = 1 / currencyRates.conversions[anyBaseCurrency][fromCurrency];

if (!(toCurrency in currencyRates.conversions[anyBaseCurrency])) {
// bid should fail, currency is not supported
throw new Error('Specified adServerCurrency in config \'' + toCurrency + '\' not found in the currency rates file');
}
var fromIntermediateConversionRate = currencyRates.conversions[anyBaseCurrency][toCurrency];

conversionRate = roundFloat(toIntermediateConversionRate * fromIntermediateConversionRate, CURRENCY_RATE_PRECISION);
utils.logInfo('getCurrencyConversion using intermediate ' + fromCurrency + ' thru ' + anyBaseCurrency + ' to ' + toCurrency + ' conversionRate ' + conversionRate);
}
}
if (!(fromCurrency in conversionCache)) {
utils.logMessage('Adding conversionCache value ' + conversionRate + ' for fromCurrency ' + fromCurrency);
conversionCache[fromCurrency] = conversionRate;
}
return conversionRate;
}

function roundFloat(num, dec) {
var d = 1;
for (let i = 0; i < dec; i++) {
d += '0';
}
return Math.round(num * d) / d;
}
6 changes: 5 additions & 1 deletion src/bidmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,11 @@ exports.addBidResponse = function (adUnitCode, bid) {
bid.renderer.setRender(adUnitRenderer.render);
}

const priceStringsObj = getPriceBucketString(bid.cpm, config.getConfig('customPriceBucket'));
const priceStringsObj = getPriceBucketString(
bid.cpm,
config.getConfig('customPriceBucket'),
config.getConfig('currency.granularityMultiplier')
);
bid.pbLg = priceStringsObj.low;
bid.pbMg = priceStringsObj.med;
bid.pbHg = priceStringsObj.high;
Expand Down
10 changes: 5 additions & 5 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Defining and access properties in this way is now deprecated, but these will
* continue to work during a deprecation window.
*/
import { isValidePriceConfig } from './cpmBucketManager';
import { isValidPriceConfig } from './cpmBucketManager';
const utils = require('./utils');

const DEFAULT_DEBUG = false;
Expand Down Expand Up @@ -134,7 +134,7 @@ export function newConfig() {
utils.logWarn('Prebid Warning: setPriceGranularity was called with invalid setting, using `medium` as default.');
}
} else if (typeof val === 'object') {
if (!isValidePriceConfig(val)) {
if (!isValidPriceConfig(val)) {
utils.logError('Invalid custom price value passed to `setPriceGranularity()`');
return false;
}
Expand All @@ -144,16 +144,16 @@ export function newConfig() {

/*
* Returns configuration object if called without parameters,
* or single configuration property if given a string matching a configuartion
* property name.
* or single configuration property if given a string matching a configuration
* property name. Allows deep access e.g. getConfig('currency.adServerCurrency')
*
* If called with callback parameter, or a string and a callback parameter,
* subscribes to configuration updates. See `subscribe` function for usage.
*/
function getConfig(...args) {
if (args.length <= 1 && typeof args[0] !== 'function') {
const option = args[0];
return option ? config[option] : config;
return option ? utils.deepAccess(config, option) : config;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the JSDocs with the new functionality

}

return subscribe(...args);
Expand Down
37 changes: 18 additions & 19 deletions src/cpmBucketManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,26 +57,25 @@ const _autoPriceConfig = {
}]
};

function getPriceBucketString(cpm, customConfig) {
let cpmFloat = 0;
cpmFloat = parseFloat(cpm);
function getPriceBucketString(cpm, customConfig, granularityMultiplier = 1) {
let cpmFloat = parseFloat(cpm);
if (isNaN(cpmFloat)) {
cpmFloat = '';
}

return {
low: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _lgPriceConfig),
med: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _mgPriceConfig),
high: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _hgPriceConfig),
auto: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _autoPriceConfig),
dense: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _densePriceConfig),
custom: (cpmFloat === '') ? '' : getCpmStringValue(cpm, customConfig)
low: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _lgPriceConfig, granularityMultiplier),
med: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _mgPriceConfig, granularityMultiplier),
high: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _hgPriceConfig, granularityMultiplier),
auto: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _autoPriceConfig, granularityMultiplier),
dense: (cpmFloat === '') ? '' : getCpmStringValue(cpm, _densePriceConfig, granularityMultiplier),
custom: (cpmFloat === '') ? '' : getCpmStringValue(cpm, customConfig, granularityMultiplier)
};
}

function getCpmStringValue(cpm, config) {
function getCpmStringValue(cpm, config, granularityMultiplier) {
let cpmStr = '';
if (!isValidePriceConfig(config)) {
if (!isValidPriceConfig(config)) {
return cpmStr;
}
const cap = config.buckets.reduce((prev, curr) => {
Expand All @@ -88,20 +87,20 @@ function getCpmStringValue(cpm, config) {
'max': 0,
});
let bucket = config.buckets.find(bucket => {
if (cpm > cap.max) {
if (cpm > cap.max * granularityMultiplier) {
const precision = bucket.precision || _defaultPrecision;
cpmStr = bucket.max.toFixed(precision);
} else if (cpm <= bucket.max && cpm >= bucket.min) {
cpmStr = (bucket.max * granularityMultiplier).toFixed(precision);
} else if (cpm <= bucket.max * granularityMultiplier && cpm >= bucket.min * granularityMultiplier) {
return bucket;
}
});
if (bucket) {
cpmStr = getCpmTarget(cpm, bucket.increment, bucket.precision);
cpmStr = getCpmTarget(cpm, bucket.increment, bucket.precision, granularityMultiplier);
}
return cpmStr;
}

function isValidePriceConfig(config) {
function isValidPriceConfig(config) {
if (utils.isEmpty(config) || !config.buckets || !Array.isArray(config.buckets)) {
return false;
}
Expand All @@ -114,12 +113,12 @@ function isValidePriceConfig(config) {
return isValid;
}

function getCpmTarget(cpm, increment, precision) {
function getCpmTarget(cpm, increment, precision, granularityMultiplier) {
if (!precision) {
precision = _defaultPrecision;
}
let bucketSize = 1 / increment;
let bucketSize = 1 / increment * granularityMultiplier;
return (Math.floor(cpm * bucketSize) / bucketSize).toFixed(precision);
}

export { getPriceBucketString, isValidePriceConfig };
export { getPriceBucketString, isValidPriceConfig };
Loading