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

feat: directional premiums for referrals #837

Merged
merged 4 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
56 changes: 46 additions & 10 deletions lib/db/models/Referral.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { DataTypes, Model, Sequelize } from 'sequelize';
import { SwapType } from '../../consts/Enums';
import { OrderSide, SwapType } from '../../consts/Enums';

type Limits = {
minimal?: number;
maximal?: number;
};

// TODO: direction of chain swaps
type Premiums = Partial<Record<SwapType, number>>;
type DirectionalPremium = Partial<{
[OrderSide.BUY]: number;
[OrderSide.SELL]: number;
}>;

type Premiums = Partial<{
[SwapType.Submarine]: number;
[SwapType.ReverseSubmarine]: number;
[SwapType.Chain]: DirectionalPremium;
}>;

type LimitsPerType = Partial<Record<SwapType, Limits>>;

Expand Down Expand Up @@ -131,27 +139,55 @@ class Referral extends Model implements ReferralType {
return this.config?.limits?.[type];
};

public premium = (pair: string, type: SwapType): number | undefined => {
return (
public premium = (
pair: string,
type: SwapType,
orderSide: OrderSide,
): number | undefined => {
const premium =
this.config?.pairs?.[pair]?.premiums?.[type] ||
this.config?.premiums?.[type]
);
this.config?.premiums?.[type];

if (type === SwapType.Chain) {
return premium !== undefined ? premium[orderSide] : undefined;
}

return premium as number | undefined;
};

public premiumForPairs = (
pairs: string[],
type: SwapType,
orderSide?: OrderSide,
): number | undefined => {
if (type === SwapType.Chain && orderSide === undefined) {
throw new Error('chain swap premiums require an order side');
}

for (const pair of pairs) {
const premium = this.config?.pairs?.[pair]?.premiums?.[type];
if (premium !== undefined) {
return premium;
if (type === SwapType.Chain) {
return premium[orderSide!];
}
return premium as number | undefined;
}
}

return this.config?.premiums?.[type];
const premium = this.config?.premiums?.[type];
if (type === SwapType.Chain && premium !== undefined) {
return premium[orderSide!];
}

return premium as number | undefined;
};
}

export default Referral;
export { ReferralType, ReferralConfig, ReferralPairConfig };
export {
ReferralType,
ReferralConfig,
ReferralPairConfig,
DirectionalPremium,
Premiums,
};
76 changes: 65 additions & 11 deletions lib/db/repositories/ReferralRepository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { QueryTypes } from 'sequelize';
import { SuccessSwapUpdateEvents } from '../../consts/Enums';
import {
OrderSide,
SuccessSwapUpdateEvents,
SwapType,
} from '../../consts/Enums';
import Database from '../Database';
import Referral, {
DirectionalPremium,
ReferralConfig,
ReferralPairConfig,
ReferralType,
Expand Down Expand Up @@ -38,6 +43,13 @@ class ReferralRepository {
ORDER BY year, month;
`;

private static readonly maxPremiumPercentage = 100;
private static readonly minPremiumPercentage = -100;
private static readonly minExpiration = 120;
private static readonly maxExpiration = 60 * 60 * 24;
private static readonly minRoutingFee = 0;
private static readonly maxRoutingFee = 0.005;

public static addReferral = async (
referral: ReferralType,
): Promise<Referral> => {
Expand Down Expand Up @@ -112,23 +124,67 @@ class ReferralRepository {
private static sanityCheckConfig = (
config: ReferralConfig | null | undefined,
) => {
const sanityCheckPairConfig = (cfg: ReferralPairConfig) => {
if (cfg.maxRoutingFee) {
if (cfg.maxRoutingFee < 0 || cfg.maxRoutingFee > 0.005) {
throw 'maxRoutingFee out of range';
const validateDirectionalPremium = (
premium: unknown,
): DirectionalPremium => {
if (typeof premium === 'object') {
const premiumObj = premium as Record<string, unknown>;
if (
(OrderSide.BUY in premiumObj &&
typeof premiumObj[OrderSide.BUY] !== 'number') ||
(OrderSide.SELL in premiumObj &&
typeof premiumObj[OrderSide.SELL] !== 'number')
) {
throw 'premium values must be numbers';
}
}

return premium as DirectionalPremium;
};

const sanityCheckPairConfig = (cfg: ReferralPairConfig) => {
if (
cfg.maxRoutingFee !== undefined &&
(cfg.maxRoutingFee < ReferralRepository.minRoutingFee ||
cfg.maxRoutingFee > ReferralRepository.maxRoutingFee)
) {
throw 'maxRoutingFee out of range';
}

if (cfg.premiums) {
if (Object.values(cfg.premiums).some((p) => p < -100 || p > 100)) {
throw 'premium out of range';
for (const [typeStr, premium] of Object.entries(cfg.premiums)) {
const type = Number(typeStr) as SwapType;

if (type === SwapType.Chain) {
const directionalPremium = validateDirectionalPremium(premium);
Object.values(directionalPremium).forEach((p) => {
if (
p < ReferralRepository.minPremiumPercentage ||
p > ReferralRepository.maxPremiumPercentage
) {
throw 'premium out of range';
}
});
} else {
if (typeof premium !== 'number') {
throw 'premium must be a number';
}
if (
premium < ReferralRepository.minPremiumPercentage ||
premium > ReferralRepository.maxPremiumPercentage
) {
throw 'premium out of range';
}
}
}
}

if (cfg.expirations) {
if (
Object.values(cfg.expirations).some(
(e) => e < 120 || e > 60 * 60 * 24,
(e) =>
e < ReferralRepository.minExpiration ||
e > ReferralRepository.maxExpiration,
)
) {
throw 'expiration out of range';
Expand All @@ -140,9 +196,7 @@ class ReferralRepository {
sanityCheckPairConfig(config);

if (config.pairs) {
for (const pair of Object.values(config.pairs)) {
sanityCheckPairConfig(pair);
}
Object.values(config.pairs).forEach(sanityCheckPairConfig);
}
}
};
Expand Down
2 changes: 1 addition & 1 deletion lib/rates/FeeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ class FeeProvider {
typeof percentageType === 'number'
? percentageType
: percentageType[orderSide],
referral?.premium(pair, type),
referral?.premium(pair, type, orderSide),
);

return feeType === PercentageFeeType.Calculation
Expand Down
17 changes: 16 additions & 1 deletion lib/rates/providers/RateProviderTaproot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,22 @@ class RateProviderTaproot extends RateProviderBase<SwapTypes> {
[to, from],
].map(([base, quote]) => getPairId({ base, quote }));

const premium = referral?.premiumForPairs(pairIds, type);
const getConfigPairId = (pairIds: string[]) => {
for (const pairId of pairIds) {
if (this.pairConfigs.has(pairId)) {
return pairId;
}
}
throw new Error('could not find pair id');
};

const { quote } = splitPairId(getConfigPairId(pairIds));

const premium = referral?.premiumForPairs(
pairIds,
type,
to === quote ? OrderSide.SELL : OrderSide.BUY,
);
const limits = referral?.limitsForPairs(pairIds, type);

const result = {
Expand Down
42 changes: 25 additions & 17 deletions test/integration/db/models/Referral.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Logger from '../../../../lib/Logger';
import { createApiCredential } from '../../../../lib/Utils';
import { SwapType } from '../../../../lib/consts/Enums';
import { OrderSide, SwapType } from '../../../../lib/consts/Enums';
import Database from '../../../../lib/db/Database';
import ReferralRepository, {
ReferralType,
Expand All @@ -19,6 +19,7 @@ describe('Referral', () => {
premiums: {
[SwapType.Submarine]: -15,
[SwapType.ReverseSubmarine]: 21,
[SwapType.Chain]: { [OrderSide.BUY]: -15, [OrderSide.SELL]: -10 },
},
limits: {
[SwapType.ReverseSubmarine]: {
Expand All @@ -30,7 +31,7 @@ describe('Referral', () => {
'RBTC/BTC': {
maxRoutingFee: 0.0025,
premiums: {
[SwapType.Chain]: -25,
[SwapType.Chain]: { [OrderSide.BUY]: -25, [OrderSide.SELL]: -20 },
},
limits: {
[SwapType.Submarine]: {
Expand Down Expand Up @@ -108,29 +109,36 @@ describe('Referral', () => {
});

test.each`
pair | type | expected
${'BTC/BTC'} | ${SwapType.Submarine} | ${referralValues.config!.premiums![SwapType.Submarine]}
${'BTC/BTC'} | ${SwapType.ReverseSubmarine} | ${referralValues.config!.premiums![SwapType.ReverseSubmarine]}
${'BTC/BTC'} | ${SwapType.Chain} | ${referralValues.config!.premiums![SwapType.Chain]}
${'RBTC/BTC'} | ${SwapType.Chain} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]}
pair | type | orderSide | expected
${'BTC/BTC'} | ${SwapType.Submarine} | ${undefined} | ${referralValues.config!.premiums![SwapType.Submarine]}
${'BTC/BTC'} | ${SwapType.ReverseSubmarine} | ${undefined} | ${referralValues.config!.premiums![SwapType.ReverseSubmarine]}
${'BTC/BTC'} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.BUY]}
${'BTC/BTC'} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.SELL]}
${'RBTC/BTC'} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.BUY]}
${'RBTC/BTC'} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.SELL]}
`(
'should get premium for pair $pair and type $type',
async ({ pair, type, expected }) => {
async ({ pair, type, orderSide, expected }) => {
const ref = await ReferralRepository.getReferralById(referralValues.id);

expect(ref).not.toBeNull();
expect(ref!.premium(pair, type)).toEqual(expected);
expect(ref!.premium(pair, type, orderSide)).toEqual(expected);
},
);

test.each`
pairs | type | expected
${['BTC/BTC']} | ${SwapType.Chain} | ${referralValues.config!.premiums![SwapType.Chain]}
${['BTC/BTC', 'RBTC/BTC']} | ${SwapType.Chain} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]}
`('should get premium for pairs', async ({ pairs, type, expected }) => {
const ref = await ReferralRepository.getReferralById(referralValues.id);
pairs | type | orderSide | expected
${['BTC/BTC']} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.BUY]}
${['BTC/BTC']} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.SELL]}
${['BTC/BTC', 'RBTC/BTC']} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.BUY]}
${['BTC/BTC', 'RBTC/BTC']} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.SELL]}
`(
'should get premium for pairs',
async ({ pairs, type, orderSide, expected }) => {
const ref = await ReferralRepository.getReferralById(referralValues.id);

expect(ref).not.toBeNull();
expect(ref!.premiumForPairs(pairs, type)).toEqual(expected);
});
expect(ref).not.toBeNull();
expect(ref!.premiumForPairs(pairs, type, orderSide)).toEqual(expected);
},
);
});
76 changes: 75 additions & 1 deletion test/integration/db/repositories/ReferralRepository.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Logger from '../../../../lib/Logger';
import { SwapType } from '../../../../lib/consts/Enums';
import { OrderSide, SwapType } from '../../../../lib/consts/Enums';
import Database from '../../../../lib/db/Database';
import Referral from '../../../../lib/db/models/Referral';
import ReferralRepository from '../../../../lib/db/repositories/ReferralRepository';
Expand Down Expand Up @@ -148,6 +148,80 @@ describe('ReferralRepository', () => {
).rejects.toEqual(`${field} out of range`);
},
);

test.each`
premium | description
${{ [OrderSide.BUY]: 'not-a-number' }} | ${'invalid BUY value type'}
${{ [OrderSide.SELL]: 'not-a-number' }} | ${'invalid SELL value type'}
${{ [OrderSide.BUY]: true }} | ${'invalid BUY value type'}
${{ [OrderSide.SELL]: {} }} | ${'invalid SELL value type'}
${{ [OrderSide.BUY]: () => {} }} | ${'invalid BUY value type'}
${{ [OrderSide.SELL]: [1, 2] }} | ${'invalid SELL value type'}
`(
'should throw if chain swap premium has $description',
async ({ premium }) => {
await expect(
ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: premium,
},
},
}),
).rejects.toEqual('premium values must be numbers');
},
);

test.each`
premium | description
${{}} | ${'empty object'}
${{ [OrderSide.BUY]: 10 }} | ${'only BUY'}
${{ [OrderSide.SELL]: 10 }} | ${'only SELL'}
${{ [OrderSide.BUY]: 10, [OrderSide.SELL]: 10 }} | ${'both BUY and SELL'}
`(
'should accept valid chain swap premium ($description)',
async ({ premium }) => {
const ref = await ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: premium,
},
},
});

expect(ref.config?.premiums?.[SwapType.Chain]).toEqual(premium);
},
);

test.each`
buy | sell
${10} | ${20}
${-100} | ${-100}
${0} | ${0}
${100} | ${100}
`(
'should accept valid chain swap premiums (buy: $buy, sell: $sell)',
async ({ buy, sell }) => {
const ref = await ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: {
[OrderSide.BUY]: buy,
[OrderSide.SELL]: sell,
},
},
},
});

expect(ref.config?.premiums?.[SwapType.Chain]).toEqual({
[OrderSide.BUY]: buy,
[OrderSide.SELL]: sell,
});
},
);
});
});
});
Loading
Loading