diff --git a/migration/1768494786424-ScryptTradingActions.js b/migration/1768494786424-ScryptTradingActions.js new file mode 100644 index 0000000000..5dcf7ff677 --- /dev/null +++ b/migration/1768494786424-ScryptTradingActions.js @@ -0,0 +1,103 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Add Scrypt SELL actions for CHF->USDT and EUR->USDT trading + * and link them to existing rules 312 (CHF) and 313 (EUR) + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class ScryptTradingActions1768494786424 { + name = 'ScryptTradingActions1768494786424' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Create Scrypt SELL action for CHF -> USDT + await queryRunner.query(` + INSERT INTO "dbo"."liquidity_management_action" ("system", "command", "params") + VALUES ('Scrypt', 'sell', '{"tradeAsset":"USDT"}') + `); + + // Get the ID of the newly created CHF action + const chfActionResult = await queryRunner.query(` + SELECT TOP 1 "id" FROM "dbo"."liquidity_management_action" + WHERE "system" = 'Scrypt' AND "command" = 'sell' + ORDER BY "id" DESC + `); + const chfActionId = chfActionResult[0].id; + + // Update Rule 312 (Scrypt CHF) with maximal=1000 and link to SELL action + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = 0, + "optimal" = 0, + "maximal" = 1000, + "redundancyStartActionId" = ${chfActionId} + WHERE "id" = 312 + `); + + // Create Scrypt SELL action for EUR -> USDT (separate action for clarity) + await queryRunner.query(` + INSERT INTO "dbo"."liquidity_management_action" ("system", "command", "params") + VALUES ('Scrypt', 'sell', '{"tradeAsset":"USDT"}') + `); + + // Get the ID of the newly created EUR action + const eurActionResult = await queryRunner.query(` + SELECT TOP 1 "id" FROM "dbo"."liquidity_management_action" + WHERE "system" = 'Scrypt' AND "command" = 'sell' + ORDER BY "id" DESC + `); + const eurActionId = eurActionResult[0].id; + + // Update Rule 313 (Scrypt EUR) with maximal=1000 and link to SELL action + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = 0, + "optimal" = 0, + "maximal" = 1000, + "redundancyStartActionId" = ${eurActionId} + WHERE "id" = 313 + `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + // Get action IDs linked to rules 312 and 313 + const rule312 = await queryRunner.query(` + SELECT "redundancyStartActionId" FROM "dbo"."liquidity_management_rule" WHERE "id" = 312 + `); + const rule313 = await queryRunner.query(` + SELECT "redundancyStartActionId" FROM "dbo"."liquidity_management_rule" WHERE "id" = 313 + `); + + // Remove action links from rules and reset values + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = NULL, + "optimal" = NULL, + "maximal" = NULL, + "redundancyStartActionId" = NULL + WHERE "id" IN (312, 313) + `); + + // Delete actions if they exist + if (rule312[0]?.redundancyStartActionId) { + await queryRunner.query(` + DELETE FROM "dbo"."liquidity_management_action" WHERE "id" = ${rule312[0].redundancyStartActionId} + `); + } + if (rule313[0]?.redundancyStartActionId && rule313[0].redundancyStartActionId !== rule312[0]?.redundancyStartActionId) { + await queryRunner.query(` + DELETE FROM "dbo"."liquidity_management_action" WHERE "id" = ${rule313[0].redundancyStartActionId} + `); + } + } +} diff --git a/src/integration/exchange/dto/scrypt.dto.ts b/src/integration/exchange/dto/scrypt.dto.ts new file mode 100644 index 0000000000..be4b0e1e41 --- /dev/null +++ b/src/integration/exchange/dto/scrypt.dto.ts @@ -0,0 +1,188 @@ +// --- TRANSACTION TYPES --- // + +export enum ScryptTransactionType { + WITHDRAWAL = 'Withdrawal', + DEPOSIT = 'Deposit', +} + +export enum ScryptTransactionStatus { + COMPLETED = 'Completed', + FAILED = 'Failed', + REJECTED = 'Rejected', +} + +export interface ScryptBalance { + Currency: string; + Amount: string; + AvailableAmount: string; + Equivalent?: { + Currency: string; + Amount: string; + AvailableAmount: string; + }; +} + +export interface ScryptBalanceTransaction { + TransactionID: string; + ClReqID?: string; + Currency: string; + TransactionType: ScryptTransactionType; + Status: ScryptTransactionStatus; + Quantity: string; + Fee?: string; + TxHash?: string; + RejectReason?: string; + RejectText?: string; + Timestamp?: string; + TransactTime?: string; +} + +export interface ScryptWithdrawResponse { + id: string; + status: ScryptTransactionStatus; +} + +export interface ScryptWithdrawStatus { + id: string; + status: ScryptTransactionStatus; + txHash?: string; + amount?: number; + rejectReason?: string; + rejectText?: string; +} + +// --- TRADE TYPES --- // + +export enum ScryptTradeSide { + BUY = 'Buy', + SELL = 'Sell', +} + +export enum ScryptTradeStatus { + PENDING = 'Pending', + CONFIRMED = 'Confirmed', + CANCELED = 'Canceled', +} + +export interface ScryptTrade { + Timestamp: string; + Symbol: string; + OrderID: string; + TradeID: string; + Side: ScryptTradeSide; + TransactTime: string; + ExecType: string; + Currency: string; + Price?: string; + Quantity: string; + Amount: string; + Fee: string; + FeeCurrency?: string; + TradeStatus: ScryptTradeStatus; + AmountCurrency: string; + QuoteID?: string; + RFQID?: string; + CustomerUser?: string; + AggressorSide?: ScryptTradeSide; + DealtCurrency?: string; +} + +// --- ORDER TYPES --- // + +export enum ScryptOrderStatus { + NEW = 'New', + PARTIALLY_FILLED = 'PartiallyFilled', + FILLED = 'Filled', + CANCELLED = 'Cancelled', + REJECTED = 'Rejected', +} + +export enum ScryptOrderSide { + BUY = 'Buy', + SELL = 'Sell', +} + +export enum ScryptOrderType { + MARKET = 'Market', + LIMIT = 'Limit', +} + +export enum ScryptTimeInForce { + FILL_AND_KILL = 'FillAndKill', + FILL_OR_KILL = 'FillOrKill', + GOOD_TILL_CANCEL = 'GoodTillCancel', +} + +export interface ScryptExecutionReport { + ClOrdID: string; + OrigClOrdID?: string; + OrderID?: string; + Symbol: string; + Side: string; + OrdStatus: ScryptOrderStatus; + ExecType?: string; + OrderQty: string; + CumQty: string; + LeavesQty: string; + AvgPx?: string; + Price?: string; + RejectReason?: string; + Text?: string; +} + +export interface ScryptOrderResponse { + id: string; + status: ScryptOrderStatus; +} + +export interface ScryptOrderInfo { + id: string; + orderId?: string; + symbol: string; + side: string; + status: ScryptOrderStatus; + quantity: number; + filledQuantity: number; + remainingQuantity: number; + avgPrice?: number; + price?: number; + rejectReason?: string; +} + +// --- MARKET DATA TYPES --- // + +export interface ScryptPriceLevel { + Price: string; + Size: string; +} + +export interface ScryptMarketDataSnapshot { + Timestamp: string; + Symbol: string; + Status: string; + Bids: ScryptPriceLevel[]; + Offers: ScryptPriceLevel[]; +} + +export interface ScryptOrderBook { + bids: Array<{ price: number; size: number }>; + offers: Array<{ price: number; size: number }>; +} + +// --- SECURITY TYPES --- // + +export interface ScryptSecurity { + Symbol: string; + MinimumSize?: string; + MaximumSize?: string; + MinPriceIncrement?: string; + MinSizeIncrement?: string; +} + +export interface ScryptSecurityInfo { + symbol: string; + minSize: number; + maxSize: number; + minPriceIncrement: number; + minSizeIncrement: number; +} diff --git a/src/integration/exchange/entities/exchange-tx.entity.ts b/src/integration/exchange/entities/exchange-tx.entity.ts index cc1541c066..56ffd49931 100644 --- a/src/integration/exchange/entities/exchange-tx.entity.ts +++ b/src/integration/exchange/entities/exchange-tx.entity.ts @@ -137,4 +137,5 @@ export const ExchangeSyncs: ExchangeSync[] = [ tokenReplacements: [], }, { exchange: ExchangeName.BINANCE, tradeTokens: ['BTC', 'USDT'], tokenReplacements: [['BTCB', 'BTC']] }, + { exchange: ExchangeName.SCRYPT, tokens: [], tokenReplacements: [] }, ]; diff --git a/src/integration/exchange/mappers/exchange-tx.mapper.ts b/src/integration/exchange/mappers/exchange-tx.mapper.ts index d845ea964a..b8686a0e93 100644 --- a/src/integration/exchange/mappers/exchange-tx.mapper.ts +++ b/src/integration/exchange/mappers/exchange-tx.mapper.ts @@ -1,5 +1,13 @@ import { Trade, Transaction } from 'ccxt'; import { ExchangeTxDto } from '../dto/exchange-tx.dto'; +import { + ScryptBalanceTransaction, + ScryptTrade, + ScryptTradeSide, + ScryptTradeStatus, + ScryptTransactionStatus, + ScryptTransactionType, +} from '../dto/scrypt.dto'; import { ExchangeTxType } from '../entities/exchange-tx.entity'; import { ExchangeName } from '../enums/exchange.enum'; @@ -70,4 +78,74 @@ export class ExchangeTxMapper { side: t.side, })); } + + static mapScryptTransactions(transactions: ScryptBalanceTransaction[], exchange: ExchangeName): ExchangeTxDto[] { + return transactions.map((t) => ({ + exchange, + type: this.mapScryptTransactionType(t.TransactionType), + externalId: t.TransactionID, + externalCreated: t.TransactTime ? new Date(t.TransactTime) : new Date(), + externalUpdated: t.Timestamp ? new Date(t.Timestamp) : new Date(), + status: this.mapScryptStatus(t.Status), + amount: parseFloat(t.Quantity) || 0, + feeAmount: t.Fee ? parseFloat(t.Fee) : 0, + feeCurrency: t.Currency, + currency: t.Currency, + txId: t.TxHash, + })); + } + + private static mapScryptTransactionType(type: ScryptTransactionType): ExchangeTxType { + switch (type) { + case ScryptTransactionType.DEPOSIT: + return ExchangeTxType.DEPOSIT; + case ScryptTransactionType.WITHDRAWAL: + return ExchangeTxType.WITHDRAWAL; + default: + throw new Error(`Unknown Scrypt transaction type: ${type}`); + } + } + + private static mapScryptStatus(status: ScryptTransactionStatus): string { + switch (status) { + case ScryptTransactionStatus.COMPLETED: + return 'ok'; + case ScryptTransactionStatus.FAILED: + case ScryptTransactionStatus.REJECTED: + return 'failed'; + default: + return 'pending'; + } + } + + static mapScryptTrades(trades: ScryptTrade[], exchange: ExchangeName): ExchangeTxDto[] { + return trades.map((t) => ({ + exchange, + type: ExchangeTxType.TRADE, + externalId: t.TradeID, + externalCreated: new Date(t.TransactTime), + externalUpdated: new Date(t.Timestamp), + status: this.mapScryptTradeStatus(t.TradeStatus), + amount: parseFloat(t.Quantity) || 0, + feeAmount: parseFloat(t.Fee) || 0, + feeCurrency: t.FeeCurrency ?? t.Currency, + symbol: t.Symbol.replace('-', '/'), + side: t.Side === ScryptTradeSide.BUY ? 'buy' : 'sell', + price: t.Price ? parseFloat(t.Price) : undefined, + cost: parseFloat(t.Amount) || 0, + order: t.OrderID, + })); + } + + private static mapScryptTradeStatus(status: ScryptTradeStatus): string { + switch (status) { + case ScryptTradeStatus.CONFIRMED: + return 'ok'; + case ScryptTradeStatus.CANCELED: + return 'canceled'; + case ScryptTradeStatus.PENDING: + default: + return 'pending'; + } + } } diff --git a/src/integration/exchange/services/exchange-registry.service.ts b/src/integration/exchange/services/exchange-registry.service.ts index e7b2f8c9a3..ccdce3e606 100644 --- a/src/integration/exchange/services/exchange-registry.service.ts +++ b/src/integration/exchange/services/exchange-registry.service.ts @@ -1,10 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { StrategyRegistry } from 'src/subdomains/supporting/common/strategy-registry'; +import { ExchangeName } from '../enums/exchange.enum'; import { ExchangeService } from './exchange.service'; +import { ScryptService } from './scrypt.service'; @Injectable() export class ExchangeRegistryService extends StrategyRegistry { + @Inject() private readonly scryptService: ScryptService; + protected getKey(key: string): string { return key.toLowerCase(); } + + getExchange(exchange: string): ExchangeService | ScryptService { + return exchange === ExchangeName.SCRYPT ? this.scryptService : this.get(exchange); + } } diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index 10212b0ad7..70fa6a1aa4 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -19,6 +19,7 @@ import { ExchangeName } from '../enums/exchange.enum'; import { ExchangeTxMapper } from '../mappers/exchange-tx.mapper'; import { ExchangeTxRepository } from '../repositories/exchange-tx.repository'; import { ExchangeRegistryService } from './exchange-registry.service'; +import { ScryptService } from './scrypt.service'; @Injectable() export class ExchangeTxService { @@ -121,7 +122,20 @@ export class ExchangeTxService { private async getTransactionsFor(sync: ExchangeSync, since: Date): Promise { try { - const exchangeService = this.registryService.get(sync.exchange); + const exchangeService = this.registryService.getExchange(sync.exchange); + + // Scrypt special case + if (exchangeService instanceof ScryptService) { + const [transactions, trades] = await Promise.all([ + exchangeService.getAllTransactions(since), + exchangeService.getTrades(since), + ]); + + return [ + ...ExchangeTxMapper.mapScryptTransactions(transactions, sync.exchange), + ...ExchangeTxMapper.mapScryptTrades(trades, sync.exchange), + ]; + } const tokens = sync.tokens ?? (await this.assetService.getAssetsUsedOn(sync.exchange)); diff --git a/src/integration/exchange/services/scrypt-websocket-connection.ts b/src/integration/exchange/services/scrypt-websocket-connection.ts index ef1b220ea4..fd6e02b8e7 100644 --- a/src/integration/exchange/services/scrypt-websocket-connection.ts +++ b/src/integration/exchange/services/scrypt-websocket-connection.ts @@ -22,7 +22,17 @@ export enum ScryptMessageType { NEW_WITHDRAW_REQUEST = 'NewWithdrawRequest', BALANCE_TRANSACTION = 'BalanceTransaction', BALANCE = 'Balance', + TRADE = 'Trade', ERROR = 'error', + // Trading + NEW_ORDER_SINGLE = 'NewOrderSingle', + EXECUTION_REPORT = 'ExecutionReport', + // Market Data + MARKET_DATA_SNAPSHOT = 'MarketDataSnapshot', + SECURITY = 'Security', + // Order Management + ORDER_CANCEL_REQUEST = 'OrderCancelRequest', + ORDER_CANCEL_REPLACE_REQUEST = 'OrderCancelReplaceRequest', } enum ScryptRequestType { diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index cdfaa35d9a..464b67fa6d 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -1,54 +1,28 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { GetConfig } from 'src/config/config'; +import { + ScryptBalance, + ScryptBalanceTransaction, + ScryptExecutionReport, + ScryptMarketDataSnapshot, + ScryptOrderBook, + ScryptOrderInfo, + ScryptOrderResponse, + ScryptOrderSide, + ScryptOrderStatus, + ScryptOrderType, + ScryptSecurity, + ScryptSecurityInfo, + ScryptTimeInForce, + ScryptTrade, + ScryptTransactionStatus, + ScryptTransactionType, + ScryptWithdrawResponse, + ScryptWithdrawStatus, +} from '../dto/scrypt.dto'; import { ScryptMessageType, ScryptWebSocketConnection } from './scrypt-websocket-connection'; -export enum ScryptTransactionStatus { - COMPLETE = 'Complete', - FAILED = 'Failed', - REJECTED = 'Rejected', -} - -interface ScryptBalance { - Currency: string; - Amount: string; - AvailableAmount: string; - Equivalent?: { - Currency: string; - Amount: string; - AvailableAmount: string; - }; -} - -interface ScryptBalanceTransaction { - TransactionID: string; - ClReqID?: string; - Currency: string; - TransactionType: string; - Status: ScryptTransactionStatus; - Quantity: string; - Fee?: string; - TxHash?: string; - RejectReason?: string; - RejectText?: string; - Timestamp?: string; - TransactTime?: string; -} - -export interface ScryptWithdrawResponse { - id: string; - status: ScryptTransactionStatus; -} - -export interface ScryptWithdrawStatus { - id: string; - status: ScryptTransactionStatus; - txHash?: string; - amount?: number; - rejectReason?: string; - rejectText?: string; -} - @Injectable() export class ScryptService { private readonly connection: ScryptWebSocketConnection; @@ -119,7 +93,10 @@ export class ScryptService { ScryptMessageType.BALANCE_TRANSACTION, (data) => { const transactions = data as ScryptBalanceTransaction[]; - return transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === 'Withdrawal') ?? null; + return ( + transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === ScryptTransactionType.WITHDRAWAL) ?? + null + ); }, 60000, ); @@ -138,7 +115,9 @@ export class ScryptService { async getWithdrawalStatus(clReqId: string): Promise { const transactions = await this.fetchBalanceTransactions(); - const transaction = transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === 'Withdrawal'); + const transaction = transactions.find( + (t) => t.ClReqID === clReqId && t.TransactionType === ScryptTransactionType.WITHDRAWAL, + ); if (!transaction) return null; @@ -152,8 +131,234 @@ export class ScryptService { }; } + // --- TRANSACTIONS / TRADES --- // + + async getAllTransactions(since?: Date): Promise { + const transactions = await this.fetchBalanceTransactions(); + return transactions.filter((t) => !since || (t.TransactTime && new Date(t.TransactTime) >= since)); + } + private async fetchBalanceTransactions(): Promise { const data = await this.connection.fetch(ScryptMessageType.BALANCE_TRANSACTION); return data as ScryptBalanceTransaction[]; } + + async getTrades(since?: Date): Promise { + const filters: Record = {}; + if (since) filters.StartDate = since.toISOString(); + + const data = await this.connection.fetch(ScryptMessageType.TRADE, filters); + return data as ScryptTrade[]; + } + + // --- MARKET DATA --- // + + async fetchOrderBook(symbol: string): Promise { + const data = await this.connection.fetch(ScryptMessageType.MARKET_DATA_SNAPSHOT, { Symbols: [symbol] }); + const snapshots = data as ScryptMarketDataSnapshot[]; + const snapshot = snapshots.find((s) => s.Symbol === symbol); + + if (!snapshot) { + throw new Error(`No orderbook data for symbol ${symbol}`); + } + + return { + bids: snapshot.Bids.map((b) => ({ price: parseFloat(b.Price), size: parseFloat(b.Size) })), + offers: snapshot.Offers.map((o) => ({ price: parseFloat(o.Price), size: parseFloat(o.Size) })), + }; + } + + async getCurrentPrice(symbol: string, side: ScryptOrderSide): Promise { + const orderBook = await this.fetchOrderBook(symbol); + + if (side === ScryptOrderSide.BUY) { + if (!orderBook.offers.length) throw new Error(`No offers available for ${symbol}`); + return orderBook.offers[0].price; // Best ask (lowest offer) + } else { + if (!orderBook.bids.length) throw new Error(`No bids available for ${symbol}`); + return orderBook.bids[0].price; // Best bid (highest bid) + } + } + + // --- SECURITY INFO --- // + + async getSecurityInfo(symbol: string): Promise { + const data = await this.connection.fetch(ScryptMessageType.SECURITY, { Symbols: [symbol] }); + const securities = data as ScryptSecurity[]; + const security = securities.find((s) => s.Symbol === symbol); + + if (!security) { + throw new Error(`No security info for symbol ${symbol}`); + } + + return { + symbol: security.Symbol, + minSize: parseFloat(security.MinimumSize ?? '0'), + maxSize: parseFloat(security.MaximumSize ?? '0'), + minPriceIncrement: parseFloat(security.MinPriceIncrement ?? '0'), + minSizeIncrement: parseFloat(security.MinSizeIncrement ?? '0'), + }; + } + + async getMinTradeAmount(symbol: string): Promise { + const info = await this.getSecurityInfo(symbol); + return info.minSize; + } + + // --- TRADING --- // + + async placeOrder( + symbol: string, + side: ScryptOrderSide, + quantity: number, + orderType: ScryptOrderType = ScryptOrderType.LIMIT, + timeInForce: ScryptTimeInForce = ScryptTimeInForce.GOOD_TILL_CANCEL, + price?: number, + ): Promise { + const clOrdId = randomUUID(); + + // Price is required for LIMIT orders + if (orderType === ScryptOrderType.LIMIT && price === undefined) { + throw new Error('Price is required for LIMIT orders'); + } + + const orderData: Record = { + Symbol: symbol, + ClOrdID: clOrdId, + Side: side, + OrderQty: quantity.toString(), + OrdType: orderType, + TimeInForce: timeInForce, + }; + + if (price !== undefined) { + orderData.Price = price.toString(); + } + + const orderRequest = { + type: ScryptMessageType.NEW_ORDER_SINGLE, + data: [orderData], + }; + + const report = await this.connection.requestAndWaitForUpdate( + orderRequest, + ScryptMessageType.EXECUTION_REPORT, + (data) => { + const reports = data as ScryptExecutionReport[]; + return reports.find((r) => r.ClOrdID === clOrdId) ?? null; + }, + 60000, + ); + + if (report.OrdStatus === ScryptOrderStatus.REJECTED) { + throw new Error(`Scrypt order rejected: ${report.Text ?? report.RejectReason ?? 'Unknown reason'}`); + } + + return { + id: clOrdId, + status: report.OrdStatus, + }; + } + + async sell(from: string, to: string, amount: number): Promise { + const symbol = `${from}/${to}`; + const price = await this.getCurrentPrice(symbol, ScryptOrderSide.SELL); + const response = await this.placeOrder( + symbol, + ScryptOrderSide.SELL, + amount, + ScryptOrderType.LIMIT, + ScryptTimeInForce.GOOD_TILL_CANCEL, + price, + ); + return response.id; + } + + async cancelOrder(clOrdId: string, symbol: string): Promise { + const origClOrdId = clOrdId; + const newClOrdId = randomUUID(); + + const cancelRequest = { + type: ScryptMessageType.ORDER_CANCEL_REQUEST, + data: [ + { + OrigClOrdID: origClOrdId, + ClOrdID: newClOrdId, + Symbol: symbol, + }, + ], + }; + + const report = await this.connection.requestAndWaitForUpdate( + cancelRequest, + ScryptMessageType.EXECUTION_REPORT, + (data) => { + const reports = data as ScryptExecutionReport[]; + return reports.find((r) => r.OrigClOrdID === origClOrdId || r.ClOrdID === newClOrdId) ?? null; + }, + 60000, + ); + + return report.OrdStatus === ScryptOrderStatus.CANCELLED; + } + + async editOrder(clOrdId: string, symbol: string, newQuantity: number, newPrice: number): Promise { + const origClOrdId = clOrdId; + const newClOrdId = randomUUID(); + + const replaceRequest = { + type: ScryptMessageType.ORDER_CANCEL_REPLACE_REQUEST, + data: [ + { + OrigClOrdID: origClOrdId, + ClOrdID: newClOrdId, + Symbol: symbol, + OrderQty: newQuantity.toString(), + Price: newPrice.toString(), + }, + ], + }; + + const report = await this.connection.requestAndWaitForUpdate( + replaceRequest, + ScryptMessageType.EXECUTION_REPORT, + (data) => { + const reports = data as ScryptExecutionReport[]; + return reports.find((r) => r.ClOrdID === newClOrdId) ?? null; + }, + 60000, + ); + + if (report.OrdStatus === ScryptOrderStatus.REJECTED) { + throw new Error(`Scrypt order edit rejected: ${report.Text ?? report.RejectReason ?? 'Unknown reason'}`); + } + + return newClOrdId; + } + + async getOrderStatus(clOrdId: string): Promise { + const reports = await this.fetchExecutionReports(); + const report = reports.find((r) => r.ClOrdID === clOrdId); + + if (!report) return null; + + return { + id: report.ClOrdID, + orderId: report.OrderID, + symbol: report.Symbol, + side: report.Side, + status: report.OrdStatus, + quantity: parseFloat(report.OrderQty) || 0, + filledQuantity: parseFloat(report.CumQty) || 0, + remainingQuantity: parseFloat(report.LeavesQty) || 0, + avgPrice: report.AvgPx ? parseFloat(report.AvgPx) : undefined, + price: report.Price ? parseFloat(report.Price) : undefined, + rejectReason: report.RejectReason ?? report.Text, + }; + } + + private async fetchExecutionReports(): Promise { + const data = await this.connection.fetch(ScryptMessageType.EXECUTION_REPORT); + return data as ScryptExecutionReport[]; + } } diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index cc4769df78..63f6e85d33 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { ScryptService, ScryptTransactionStatus } from 'src/integration/exchange/services/scrypt.service'; +import { ScryptOrderSide, ScryptOrderStatus, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; +import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; @@ -9,10 +10,12 @@ import { LiquidityManagementSystem } from '../../enums'; import { OrderFailedException } from '../../exceptions/order-failed.exception'; import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; import { Command, CorrelationId } from '../../interfaces'; +import { LiquidityManagementOrderRepository } from '../../repositories/liquidity-management-order.repository'; import { LiquidityActionAdapter } from './base/liquidity-action.adapter'; export enum ScryptAdapterCommands { WITHDRAW = 'withdraw', + SELL = 'sell', } @Injectable() @@ -24,10 +27,12 @@ export class ScryptAdapter extends LiquidityActionAdapter { constructor( private readonly scryptService: ScryptService, private readonly dexService: DexService, + private readonly orderRepo: LiquidityManagementOrderRepository, ) { super(LiquidityManagementSystem.SCRYPT); this.commands.set(ScryptAdapterCommands.WITHDRAW, this.withdraw.bind(this)); + this.commands.set(ScryptAdapterCommands.SELL, this.sell.bind(this)); } async checkCompletion(order: LiquidityManagementOrder): Promise { @@ -35,6 +40,9 @@ export class ScryptAdapter extends LiquidityActionAdapter { case ScryptAdapterCommands.WITHDRAW: return this.checkWithdrawCompletion(order); + case ScryptAdapterCommands.SELL: + return this.checkSellCompletion(order); + default: return false; } @@ -45,6 +53,9 @@ export class ScryptAdapter extends LiquidityActionAdapter { case ScryptAdapterCommands.WITHDRAW: return this.validateWithdrawParams(params); + case ScryptAdapterCommands.SELL: + return this.validateSellParams(params); + default: throw new Error(`Command ${command} not supported by ScryptAdapter`); } @@ -83,6 +94,37 @@ export class ScryptAdapter extends LiquidityActionAdapter { } } + private async sell(order: LiquidityManagementOrder): Promise { + const { tradeAsset } = this.parseSellParams(order.action.paramMap); + + const asset = order.pipeline.rule.targetAsset.dexName; + + const availableBalance = await this.scryptService.getAvailableBalance(asset); + const effectiveMax = Math.min(order.maxAmount, availableBalance); + + if (effectiveMax < order.minAmount) { + throw new OrderNotProcessableException( + `Scrypt: not enough balance for ${asset} (balance: ${availableBalance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); + } + + const amount = Util.floor(effectiveMax, 6); + + order.inputAmount = amount; + order.inputAsset = asset; + order.outputAsset = tradeAsset; + + try { + return await this.scryptService.sell(asset, tradeAsset, amount); + } catch (e) { + if (this.isBalanceTooLowError(e)) { + throw new OrderNotProcessableException(e.message); + } + + throw e; + } + } + // --- COMPLETION CHECKS --- // private async checkWithdrawCompletion(order: LiquidityManagementOrder): Promise { @@ -107,6 +149,135 @@ export class ScryptAdapter extends LiquidityActionAdapter { return this.dexService.checkTransferCompletion(withdrawal.txHash, blockchain); } + private async checkSellCompletion(order: LiquidityManagementOrder): Promise { + const { correlationId } = order; + const { tradeAsset } = this.parseSellParams(order.action.paramMap); + const asset = order.pipeline.rule.targetAsset.dexName; + const symbol = `${asset}/${tradeAsset}`; + + const orderInfo = await this.scryptService.getOrderStatus(correlationId); + if (!orderInfo) { + this.logger.verbose(`No order info for id ${correlationId} at ${this.scryptService.name} found`); + return false; + } + + switch (orderInfo.status) { + case ScryptOrderStatus.NEW: + case ScryptOrderStatus.PARTIALLY_FILLED: { + // Price tracking like Binance - update price if changed + const currentPrice = await this.scryptService.getCurrentPrice(symbol, ScryptOrderSide.SELL); + + // Use tolerance for float comparison to avoid unnecessary updates due to rounding + const priceChanged = orderInfo.price && Math.abs(currentPrice - orderInfo.price) > 0.000001; + if (priceChanged) { + this.logger.verbose( + `Order ${correlationId}: price changed ${orderInfo.price} -> ${currentPrice}, updating order`, + ); + + try { + const newId = await this.scryptService.editOrder( + correlationId, + symbol, + orderInfo.remainingQuantity, + currentPrice, + ); + order.updateCorrelationId(newId); + await this.orderRepo.save(order); + this.logger.verbose(`Order ${correlationId} updated to ${newId} with new price ${currentPrice}`); + } catch (e) { + // If edit fails, try to cancel and let it restart + this.logger.verbose(`Could not update order ${correlationId}, attempting cancel: ${e.message}`); + try { + await this.scryptService.cancelOrder(correlationId, symbol); + } catch (cancelError) { + this.logger.verbose(`Cancel also failed: ${cancelError.message}`); + } + } + } else { + this.logger.verbose(`Order ${correlationId} open, price is still ${currentPrice}`); + } + return false; + } + + case ScryptOrderStatus.CANCELLED: { + const minAmount = await this.scryptService.getMinTradeAmount(symbol); + const remaining = orderInfo.remainingQuantity; + + // If remaining amount is below minimum, consider complete + if (remaining < minAmount) { + this.logger.verbose( + `Order ${correlationId} cancelled with remaining ${remaining} < minAmount ${minAmount}, marking complete`, + ); + order.outputAmount = await this.aggregateSellOutput(order); + return true; + } + + // Restart order with remaining amount (like Binance) + this.logger.verbose(`Order ${correlationId} cancelled, restarting with remaining ${remaining} ${asset}`); + + try { + const newId = await this.scryptService.sell(asset, tradeAsset, remaining); + order.updateCorrelationId(newId); + await this.orderRepo.save(order); + this.logger.verbose(`Order ${correlationId} restarted as ${newId}`); + return false; + } catch (e) { + throw new OrderFailedException(`Order ${correlationId} cancelled and restart failed: ${e.message}`); + } + } + + case ScryptOrderStatus.FILLED: { + // Aggregate output from all correlation IDs (in case of restarts) + order.outputAmount = await this.aggregateSellOutput(order); + return true; + } + + case ScryptOrderStatus.REJECTED: + throw new OrderFailedException( + `Order ${correlationId} has been rejected: ${orderInfo.rejectReason ?? 'unknown reason'}`, + ); + + default: + return false; + } + } + + private async aggregateSellOutput(order: LiquidityManagementOrder): Promise { + const correlationIds = order.allCorrelationIds; + + // Fetch all orders in parallel like Binance + const orderResults = await Promise.allSettled(correlationIds.map((id) => this.scryptService.getOrderStatus(id))); + + const orders = orderResults + .filter( + (result): result is PromiseFulfilledResult>> => + result.status === 'fulfilled' && result.value !== null, + ) + .map((result) => result.value!); + + // Log failures + const failures = orderResults.filter((result) => result.status === 'rejected'); + if (failures.length > 0) { + this.logger.warn( + `Order ${order.id}: Failed to fetch ${failures.length} of ${correlationIds.length} orders. ` + + `Proceeding with ${orders.length} successful fetches.`, + ); + } + + if (orders.length === 0) { + throw new OrderFailedException(`Failed to fetch any orders for order ${order.id}`); + } + + // For SELL: output is the proceeds (filledQuantity * avgPrice) + return orders.reduce((sum, o) => { + if (o.filledQuantity > 0) { + const output = o.avgPrice ? o.filledQuantity * o.avgPrice : o.filledQuantity; + return sum + output; + } + return sum; + }, 0); + } + // --- PARAM VALIDATION --- // private validateWithdrawParams(params: Record): boolean { @@ -134,9 +305,32 @@ export class ScryptAdapter extends LiquidityActionAdapter { return { address, asset, blockchain }; } + private validateSellParams(params: Record): boolean { + try { + this.parseSellParams(params); + return true; + } catch { + return false; + } + } + + private parseSellParams(params: Record): { + tradeAsset: string; + } { + const tradeAsset = params.tradeAsset as string | undefined; + + if (!tradeAsset) { + throw new Error(`Params provided to ScryptAdapter.sell(...) command are invalid.`); + } + + return { tradeAsset }; + } + // --- HELPER METHODS --- // - private isBalanceTooLowError(_e: Error): boolean { - return false; // TODO: implement specific error check for Scrypt + private isBalanceTooLowError(e: Error): boolean { + return ['Insufficient funds', 'insufficient balance', 'Insufficient position', 'not enough balance'].some((m) => + e.message?.toLowerCase().includes(m.toLowerCase()), + ); } } diff --git a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts index 18d8768818..4495afa0e0 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts @@ -1,7 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; import { ExchangeRegistryService } from 'src/integration/exchange/services/exchange-registry.service'; -import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { Active } from 'src/shared/models/active'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; @@ -19,7 +17,6 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { constructor( private readonly exchangeRegistry: ExchangeRegistryService, - private readonly scryptService: ScryptService, private readonly orderRepo: LiquidityManagementOrderRepository, ) {} @@ -61,8 +58,7 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { async getForExchange(exchange: string, assets: LiquidityManagementAsset[]): Promise { try { - const exchangeService = - exchange === ExchangeName.SCRYPT ? this.scryptService : this.exchangeRegistry.get(exchange); + const exchangeService = this.exchangeRegistry.getExchange(exchange); const balances = await exchangeService.getTotalBalances(); return assets.map((a) => { diff --git a/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts b/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts index 54994ae65c..e2a31ac705 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts @@ -34,6 +34,7 @@ export enum BankTxType { TEST_FIAT_FIAT = 'TestFiatFiat', GSHEET = 'GSheet', KRAKEN = 'Kraken', + SCRYPT = 'Scrypt', SCB = 'SCB', CHECKOUT_LTD = 'CheckoutLtd', BANK_ACCOUNT_FEE = 'BankAccountFee', @@ -395,6 +396,7 @@ export class BankTx extends IEntity { : 0; case BankTxType.KRAKEN: + case BankTxType.SCRYPT: if ( !BankService.isBankMatching(asset, targetIban ?? this.accountIban) || (targetIban && asset.dexName !== this.instructedCurrency) diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 01c4c4b5b5..d85f5be143 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -70,6 +70,7 @@ export const TransactionBankTxTypeMapper: { [BankTxType.BANK_TX_REPEAT_CHARGEBACK]: TransactionTypeInternal.BANK_TX_REPEAT_CHARGEBACK, [BankTxType.FIAT_FIAT]: TransactionTypeInternal.FIAT_FIAT, [BankTxType.KRAKEN]: TransactionTypeInternal.KRAKEN, + [BankTxType.SCRYPT]: TransactionTypeInternal.SCRYPT, [BankTxType.SCB]: TransactionTypeInternal.SCB, [BankTxType.CHECKOUT_LTD]: TransactionTypeInternal.CHECKOUT_LTD, [BankTxType.BANK_ACCOUNT_FEE]: TransactionTypeInternal.BANK_ACCOUNT_FEE, @@ -500,6 +501,10 @@ export class BankTxService implements OnModuleInit { return BankTxType.KRAKEN; } + if (tx.name?.includes('Scrypt Digital Trading')) { + return BankTxType.SCRYPT; + } + return null; } diff --git a/src/subdomains/supporting/log/dto/log.dto.ts b/src/subdomains/supporting/log/dto/log.dto.ts index 2046f95f43..c8be3c0b84 100644 --- a/src/subdomains/supporting/log/dto/log.dto.ts +++ b/src/subdomains/supporting/log/dto/log.dto.ts @@ -58,6 +58,7 @@ export type ManualLogPosition = { export type LogPairId = { fromKraken: { eur: PairId; chf: PairId }; toKraken: { eur: PairId; chf: PairId }; + toScrypt?: { eur: PairId; chf: PairId }; }; type PairId = { @@ -108,6 +109,7 @@ type AssetLogPlusPending = { fromOlky?: number; fromKraken?: number; toKraken?: number; + toScrypt?: number; }; type AssetLogMinusPending = { diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index aabb57d1c6..953b5b1b41 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -298,6 +298,10 @@ export class LogJobService { ExchangeName.KRAKEN, [ExchangeTxType.DEPOSIT, ExchangeTxType.WITHDRAWAL], ); + const recentScryptBankTx = await this.bankTxService.getRecentExchangeTx(undefined, BankTxType.SCRYPT); + const recentScryptExchangeTx = await this.exchangeTxService.getRecentExchangeTx(undefined, ExchangeName.SCRYPT, [ + ExchangeTxType.DEPOSIT, + ]); // fixed sender and receiver data @@ -366,6 +370,32 @@ export class LogJobService { eurReceiverExchangeTx, ); + // CHF: Yapeal -> Scrypt + const chfSenderScryptBankTx = recentScryptBankTx.filter( + (b) => b.accountIban === yapealChfBank.iban && b.creditDebitIndicator === BankTxIndicator.DEBIT, + ); + const chfReceiverScryptExchangeTx = recentScryptExchangeTx.filter( + (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'CHF', + ); + + // EUR: Yapeal -> Scrypt + const eurSenderScryptBankTx = recentScryptBankTx.filter( + (b) => b.accountIban === yapealEurBank.iban && b.creditDebitIndicator === BankTxIndicator.DEBIT, + ); + const eurReceiverScryptExchangeTx = recentScryptExchangeTx.filter( + (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'EUR', + ); + + // sender and receiver data for Scrypt + const { sender: recentChfYapealScryptTx, receiver: recentChfBankTxScrypt } = this.filterSenderPendingList( + chfSenderScryptBankTx, + chfReceiverScryptExchangeTx, + ); + const { sender: recentEurYapealScryptTx, receiver: recentEurBankTxScrypt } = this.filterSenderPendingList( + eurSenderScryptBankTx, + eurReceiverScryptExchangeTx, + ); + // assetLog return assets.reduce((prev, curr) => { if ((curr.balance?.amount == null && !curr.isActive) || (curr.balance && !curr.balance.isDfxOwned)) return prev; @@ -506,6 +536,28 @@ export class LogJobService { let toKraken = pendingYapealKrakenPlusAmount + pendingChfYapealKrakenMinusAmount + pendingEurYapealKrakenMinusAmount; + // Yapeal to Scrypt + const pendingYapealScryptPlusAmount = this.getPendingBankAmount( + [curr], + [...recentChfYapealScryptTx, ...recentEurYapealScryptTx], + BankTxType.SCRYPT, + ); + const pendingChfYapealScryptMinusAmount = this.getPendingBankAmount( + [curr], + recentChfBankTxScrypt, + ExchangeTxType.DEPOSIT, + yapealChfBank.iban, + ); + const pendingEurYapealScryptMinusAmount = this.getPendingBankAmount( + [curr], + recentEurBankTxScrypt, + ExchangeTxType.DEPOSIT, + yapealEurBank.iban, + ); + + let toScrypt = + pendingYapealScryptPlusAmount + pendingChfYapealScryptMinusAmount + pendingEurYapealScryptMinusAmount; + const errors = []; if (fromKraken !== fromKrakenUnfiltered) { @@ -539,6 +591,16 @@ export class LogJobService { toKraken = 0; } + if (toScrypt < 0) { + errors.push(`toScrypt < 0`); + this.logger.verbose( + `Error in financial log, toScrypt balance < 0 for asset: ${curr.id}, pendingPlusAmount: + ${pendingYapealScryptPlusAmount}, pendingChfMinusAmount: ${pendingChfYapealScryptMinusAmount}, + pendingEurMinusAmount: ${pendingEurYapealScryptMinusAmount}`, + ); + toScrypt = 0; + } + // total pending balance const totalPlusPending = cryptoInput + @@ -546,7 +608,8 @@ export class LogJobService { bridgeOrder + pendingOlkyYapealAmount + (useUnfilteredTx ? fromKrakenUnfiltered : fromKraken) + - (useUnfilteredTx ? toKrakenUnfiltered : toKraken); + (useUnfilteredTx ? toKrakenUnfiltered : toKraken) + + toScrypt; const totalPlus = liquidity + totalPlusPending + (totalCustomBalance ?? 0); @@ -631,6 +694,7 @@ export class LogJobService { fromOlky: this.getJsonValue(pendingOlkyYapealAmount, amountType(curr)), fromKraken: this.getJsonValue(useUnfilteredTx ? fromKrakenUnfiltered : fromKraken, amountType(curr)), toKraken: this.getJsonValue(useUnfilteredTx ? toKrakenUnfiltered : toKraken, amountType(curr)), + toScrypt: this.getJsonValue(toScrypt, amountType(curr)), } : undefined, // monitoring: errors.length diff --git a/src/subdomains/supporting/payment/entities/transaction.entity.ts b/src/subdomains/supporting/payment/entities/transaction.entity.ts index d3b34946b8..7bee9def4a 100644 --- a/src/subdomains/supporting/payment/entities/transaction.entity.ts +++ b/src/subdomains/supporting/payment/entities/transaction.entity.ts @@ -26,6 +26,7 @@ export enum TransactionTypeInternal { FIAT_FIAT = 'FiatFiat', INTERNAL = 'Internal', KRAKEN = 'Kraken', + SCRYPT = 'Scrypt', BANK_TX_RETURN = 'BankTxReturn', BANK_TX_REPEAT = 'BankTxRepeat', CRYPTO_INPUT_RETURN = 'CryptoInputReturn',