354 lines
8.5 KiB
TypeScript
354 lines
8.5 KiB
TypeScript
import Sequelize, { Op, WhereOptions } from 'sequelize';
|
|
import Decimal from 'decimal.js';
|
|
import { PairSortOption } from '@/interfaces/enum/pair.js';
|
|
import Currency from '../schemes/Currency.js';
|
|
import configModel from './Config.js';
|
|
import Pair from '../schemes/Pair.js';
|
|
import Transaction from '../schemes/Transaction.js';
|
|
import Order from '../schemes/Order.js';
|
|
import User from '../schemes/User.js';
|
|
|
|
const PAGE_SIZE = 6;
|
|
const BOT_RENEWAL_INTERVAL = 30 * 1000;
|
|
const MIN_RENEWAL_INTERVAL = 5 * 1000;
|
|
|
|
interface ActiveBot {
|
|
orderId: number;
|
|
userId: number;
|
|
expirationTimestamp: number;
|
|
}
|
|
|
|
interface PairWithCurrencies extends Pair {
|
|
first_currency?: Currency | null;
|
|
second_currency?: Currency | null;
|
|
whitelisted?: boolean;
|
|
}
|
|
|
|
class DexModel {
|
|
private activeBots: ActiveBot[];
|
|
|
|
constructor() {
|
|
this.activeBots = [];
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
|
|
this.activeBots = this.activeBots.filter((bot) => bot.expirationTimestamp > now);
|
|
}, BOT_RENEWAL_INTERVAL);
|
|
}
|
|
|
|
private getPairsSearchCondition(searchText: string, whitelistedOnly: boolean) {
|
|
const tickerRegexp = /^[A-Za-z0-9]{1,14}$/;
|
|
const fullNameRegexp = /^[A-Za-z0-9.,:!?\-() ]*$/;
|
|
|
|
const firstFullNameExpr = Sequelize.literal(`"first_currency"."asset_info"->>'full_name'`);
|
|
const secondFullNameExpr = Sequelize.literal(
|
|
`"second_currency"."asset_info"->>'full_name'`,
|
|
);
|
|
|
|
const searchCondition: WhereOptions = {
|
|
[Op.and]: [
|
|
Sequelize.where(Sequelize.col('first_currency.name'), {
|
|
[Op.regexp]: tickerRegexp.source,
|
|
}),
|
|
{
|
|
[Op.or]: [
|
|
Sequelize.where(Sequelize.col('first_currency.asset_info'), {
|
|
[Op.is]: null,
|
|
}),
|
|
Sequelize.where(firstFullNameExpr, { [Op.is]: null }),
|
|
{
|
|
[Op.and]: [
|
|
Sequelize.where(firstFullNameExpr, {
|
|
[Op.regexp]: fullNameRegexp.source,
|
|
}),
|
|
Sequelize.where(Sequelize.fn('char_length', firstFullNameExpr), {
|
|
[Op.lte]: 400,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
Sequelize.where(Sequelize.col('second_currency.name'), {
|
|
[Op.regexp]: tickerRegexp.source,
|
|
}),
|
|
{
|
|
[Op.or]: [
|
|
Sequelize.where(Sequelize.col('second_currency.asset_info'), {
|
|
[Op.is]: null,
|
|
}),
|
|
Sequelize.where(secondFullNameExpr, { [Op.is]: null }),
|
|
{
|
|
[Op.and]: [
|
|
Sequelize.where(secondFullNameExpr, {
|
|
[Op.regexp]: fullNameRegexp.source,
|
|
}),
|
|
Sequelize.where(Sequelize.fn('char_length', secondFullNameExpr), {
|
|
[Op.lte]: 400,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
[Op.or]: [
|
|
Sequelize.where(
|
|
Sequelize.fn('LOWER', Sequelize.col('first_currency.name')),
|
|
{
|
|
[Op.like]: `%${searchText.toLowerCase()}%`,
|
|
},
|
|
),
|
|
Sequelize.where(
|
|
Sequelize.fn('LOWER', Sequelize.col('second_currency.name')),
|
|
{
|
|
[Op.like]: `%${searchText.toLowerCase()}%`,
|
|
},
|
|
),
|
|
Sequelize.where(Sequelize.col('first_currency.asset_id'), searchText),
|
|
Sequelize.where(Sequelize.col('second_currency.asset_id'), searchText),
|
|
],
|
|
},
|
|
whitelistedOnly
|
|
? {
|
|
[Op.or]: [
|
|
Sequelize.where(Sequelize.col('first_currency.whitelisted'), true),
|
|
Sequelize.where(Sequelize.col('second_currency.whitelisted'), true),
|
|
Sequelize.where(Sequelize.col('featured'), true),
|
|
],
|
|
}
|
|
: {},
|
|
],
|
|
};
|
|
|
|
const includeCondition = [
|
|
{
|
|
model: Currency,
|
|
as: 'first_currency',
|
|
attributes: ['asset_id', 'code', 'id', 'name', 'type', 'whitelisted'],
|
|
},
|
|
{
|
|
model: Currency,
|
|
as: 'second_currency',
|
|
attributes: ['asset_id', 'code', 'id', 'name', 'type', 'whitelisted'],
|
|
},
|
|
];
|
|
|
|
return { searchCondition, includeCondition };
|
|
}
|
|
|
|
async getPairRow(id: number) {
|
|
try {
|
|
return await Pair.findByPk(id);
|
|
} catch (error) {
|
|
console.log(error);
|
|
}
|
|
}
|
|
|
|
async getPairsPage(
|
|
page: number,
|
|
searchText: string,
|
|
whitelistedOnly: boolean,
|
|
sortOption: PairSortOption,
|
|
) {
|
|
try {
|
|
const { searchCondition, includeCondition } = this.getPairsSearchCondition(
|
|
searchText,
|
|
whitelistedOnly,
|
|
);
|
|
|
|
const volumeSortDirection =
|
|
sortOption === PairSortOption.VOLUME_LOW_TO_HIGH ? 'ASC' : 'DESC';
|
|
|
|
const pairs = await Pair.findAll({
|
|
attributes: [
|
|
'id',
|
|
'first_currency_id',
|
|
'second_currency_id',
|
|
'rate',
|
|
'coefficient',
|
|
'high',
|
|
'low',
|
|
'volume',
|
|
'featured',
|
|
],
|
|
include: includeCondition,
|
|
where: searchCondition,
|
|
order: [
|
|
['volume', volumeSortDirection],
|
|
['id', 'ASC'],
|
|
],
|
|
limit: PAGE_SIZE,
|
|
offset: (page - 1) * PAGE_SIZE,
|
|
subQuery: false,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const pairsWithCurrencies: PairWithCurrencies[] = pairs.map((pair) => pair.toJSON());
|
|
|
|
for (let i = 0; i < pairs.length; i++) {
|
|
pairsWithCurrencies[i].first_currency = await configModel.getCurrencyRow(
|
|
pairs[i].first_currency_id,
|
|
);
|
|
pairsWithCurrencies[i].second_currency = await configModel.getCurrencyRow(
|
|
pairs[i].second_currency_id,
|
|
);
|
|
}
|
|
|
|
for (const pwc of pairsWithCurrencies) {
|
|
pwc.whitelisted = pwc.first_currency?.whitelisted || false;
|
|
}
|
|
|
|
return { success: true, data: pairsWithCurrencies };
|
|
} catch (err) {
|
|
console.log(err);
|
|
return { success: false, data: 'Internal error' };
|
|
}
|
|
}
|
|
|
|
async getPairsPagesAmount(searchText: string, whitelistedOnly: boolean) {
|
|
try {
|
|
const { searchCondition, includeCondition } = this.getPairsSearchCondition(
|
|
searchText,
|
|
whitelistedOnly,
|
|
);
|
|
|
|
const count = await Pair.count({
|
|
include: includeCondition,
|
|
where: searchCondition,
|
|
});
|
|
|
|
const result = Math.ceil(count / PAGE_SIZE);
|
|
|
|
return { success: true, data: result };
|
|
} catch (err) {
|
|
console.log(err);
|
|
return { success: false, data: 'Internal error' };
|
|
}
|
|
}
|
|
|
|
async getPair(id: string) {
|
|
try {
|
|
const pair = await this.getPairRow(parseInt(id, 10));
|
|
|
|
if (!pair) return { success: false, data: 'Invalid pair data' };
|
|
|
|
const pairWithCurrencies: Pair = pair;
|
|
|
|
const first_currency = await configModel.getCurrencyRow(pair.first_currency_id);
|
|
const second_currency = await configModel.getCurrencyRow(pair.second_currency_id);
|
|
|
|
const result: PairWithCurrencies = {
|
|
...(pairWithCurrencies?.toJSON() || {}),
|
|
first_currency,
|
|
second_currency,
|
|
} as PairWithCurrencies;
|
|
|
|
return { success: true, data: result };
|
|
} catch (err) {
|
|
console.log(err);
|
|
return { success: false, data: 'Internal error' };
|
|
}
|
|
}
|
|
|
|
async renewBotExpiration(orderId: number, userId: number) {
|
|
const existingBot = this.activeBots.find(
|
|
(bot) => bot.orderId === orderId && bot.userId === userId,
|
|
);
|
|
|
|
const expirationTimestamp = Date.now() + BOT_RENEWAL_INTERVAL;
|
|
|
|
if (existingBot) {
|
|
const pastFromLastRenewal =
|
|
BOT_RENEWAL_INTERVAL + Date.now() - existingBot.expirationTimestamp;
|
|
|
|
if (pastFromLastRenewal < MIN_RENEWAL_INTERVAL) {
|
|
return {
|
|
success: false,
|
|
data: `
|
|
Bot renewal interval is too short.
|
|
Minimum interval is ${MIN_RENEWAL_INTERVAL / 1000} seconds.
|
|
Maximum interval is ${BOT_RENEWAL_INTERVAL / 1000} seconds.
|
|
`,
|
|
};
|
|
}
|
|
|
|
existingBot.expirationTimestamp = expirationTimestamp;
|
|
} else {
|
|
this.activeBots.push({
|
|
orderId,
|
|
userId,
|
|
expirationTimestamp,
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
expirationTimestamp,
|
|
},
|
|
};
|
|
}
|
|
|
|
async volumeStats(address: string, pairID: number, from: number, to: number) {
|
|
const targetUser = await User.findOne({
|
|
where: {
|
|
address,
|
|
},
|
|
});
|
|
|
|
if (!targetUser) return { success: false, data: 'User not found' };
|
|
|
|
const allOrders = await Order.findAll({
|
|
where: {
|
|
user_id: targetUser.id,
|
|
pair_id: pairID,
|
|
},
|
|
});
|
|
|
|
const ordersIds = allOrders.map((order) => order.id);
|
|
|
|
const transactions = await Transaction.findAll({
|
|
where: {
|
|
[Op.or]: [
|
|
{
|
|
buy_order_id: {
|
|
[Op.in]: ordersIds,
|
|
},
|
|
},
|
|
{
|
|
sell_order_id: {
|
|
[Op.in]: ordersIds,
|
|
},
|
|
},
|
|
],
|
|
timestamp: {
|
|
[Op.between]: [from, to],
|
|
},
|
|
},
|
|
});
|
|
|
|
const volume = transactions.reduce(
|
|
(acc, transaction) => new Decimal(acc).add(new Decimal(transaction.amount)),
|
|
new Decimal(0),
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
volume: volume.toString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
getActiveBots() {
|
|
return this.activeBots;
|
|
}
|
|
|
|
isBotActive(orderId: number) {
|
|
return this.activeBots.some((bot) => bot.orderId === orderId);
|
|
}
|
|
}
|
|
|
|
const dexModel = new DexModel();
|
|
|
|
export default dexModel;
|