Skip to content

Commit 3912602

Browse files
frooozeclaude
andcommitted
feat: implement fee caching and deduction system
Add comprehensive fee management system that: - Caches blockchain operation fees and market fees - Automatically detects assets from bots.json configuration - Always includes BTS for blockchain fees - Calculates net amounts after market fee deduction - Tracks BTS blockchain fees during order fills (create + update) - Deducts accumulated BTS fees from new order sizes during rotation - Prevents funds from going negative by deducting fees before order creation New functions in utils.js: - initializeFeeCache(): Load all fees from blockchain and config - getAssetFees(): Get net amount after all fees deducted - getCachedFees(): Check cached fee data - clearFeeCache(): Reset cache Manager updates: - Added funds.btsFeesOwed tracking - Track BTS fees on every fully filled order - Reduce new order size by accumulated fees during rotation - Support both BTS as base (assetA) and quote (assetB) Includes test scripts for fee extraction and caching 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent b23d441 commit 3912602

File tree

5 files changed

+879
-6
lines changed

5 files changed

+879
-6
lines changed

modules/order/manager.js

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
* 4. After rotation: pendingProceeds cleared as funds are consumed by new orders
3636
*/
3737
const { ORDER_TYPES, ORDER_STATES, DEFAULT_CONFIG, TIMING, GRID_LIMITS } = require('./constants');
38-
const { parsePercentageString, blockchainToFloat, floatToBlockchainInt, resolveRelativePrice, calculatePriceTolerance, checkPriceWithinTolerance, parseChainOrder, findMatchingGridOrderByOpenOrder, findMatchingGridOrderByHistory, applyChainSizeToGridOrder, correctOrderPriceOnChain, getMinOrderSize } = require('./utils');
38+
const { parsePercentageString, blockchainToFloat, floatToBlockchainInt, resolveRelativePrice, calculatePriceTolerance, checkPriceWithinTolerance, parseChainOrder, findMatchingGridOrderByOpenOrder, findMatchingGridOrderByHistory, applyChainSizeToGridOrder, correctOrderPriceOnChain, getMinOrderSize, getAssetFees } = require('./utils');
3939
const Logger = require('./logger');
4040
// Grid functions (initialize/recalculate) are intended to be
4141
// called directly via require('./grid').initializeGrid(manager) by callers.
@@ -351,7 +351,8 @@ class OrderManager {
351351
grid: { buy: 0, sell: 0 },
352352
chain: { buy: 0, sell: 0 }
353353
},
354-
pendingProceeds: { buy: 0, sell: 0 } // Proceeds from fills awaiting rotation
354+
pendingProceeds: { buy: 0, sell: 0 }, // Proceeds from fills awaiting rotation
355+
btsFeesOwed: 0 // BTS blockchain fees from filled orders (only if BTS is in pair)
355356
};
356357
// Make reserved an alias for virtuel
357358
this.funds.reserved = this.funds.virtuel;
@@ -1141,9 +1142,13 @@ class OrderManager {
11411142
let deltaBuyTotal = 0;
11421143
let deltaSellTotal = 0;
11431144

1145+
// Check if BTS is in the trading pair and track BTS fees only if it is
1146+
const hasBtsPair = this.config.assetA === 'BTS' || this.config.assetB === 'BTS';
1147+
let btsFeesDuedThisFill = 0;
1148+
11441149
for (const filledOrder of filledOrders) {
11451150
filledCounts[filledOrder.type]++;
1146-
1151+
11471152
// Calculate proceeds before converting to SPREAD
11481153
if (filledOrder.type === ORDER_TYPES.SELL) {
11491154
const proceeds = filledOrder.size * filledOrder.price;
@@ -1156,6 +1161,13 @@ class OrderManager {
11561161
const quoteName = this.config.assetB || 'quote';
11571162
const baseName = this.config.assetA || 'base';
11581163
this.logger.log(`Sell filled: +${proceeds.toFixed(8)} ${quoteName}, -${filledOrder.size.toFixed(8)} ${baseName} committed`, 'info');
1164+
1165+
// Add BTS blockchain fees if BTS is in pair (quote asset fee only when selling)
1166+
if (hasBtsPair && this.config.assetB === 'BTS') {
1167+
const btsFee = getAssetFees('BTS', filledOrder.size);
1168+
btsFeesDuedThisFill += btsFee;
1169+
this.logger.log(`BTS blockchain fee for sell order: ${btsFee.toFixed(8)} BTS`, 'debug');
1170+
}
11591171
} else {
11601172
const proceeds = filledOrder.size / filledOrder.price;
11611173
proceedsSell += proceeds; // Collect, don't add yet
@@ -1167,8 +1179,15 @@ class OrderManager {
11671179
const quoteName = this.config.assetB || 'quote';
11681180
const baseName = this.config.assetA || 'base';
11691181
this.logger.log(`Buy filled: +${proceeds.toFixed(8)} ${baseName}, -${filledOrder.size.toFixed(8)} ${quoteName} committed`, 'info');
1182+
1183+
// Add BTS blockchain fees if BTS is in pair (base asset fee only when buying)
1184+
if (hasBtsPair && this.config.assetA === 'BTS') {
1185+
const btsFee = getAssetFees('BTS', filledOrder.size);
1186+
btsFeesDuedThisFill += btsFee;
1187+
this.logger.log(`BTS blockchain fee for buy order: ${btsFee.toFixed(8)} BTS`, 'debug');
1188+
}
11701189
}
1171-
1190+
11721191
// Convert directly to SPREAD placeholder (one step: ACTIVE -> VIRTUAL/SPREAD)
11731192
// Create copy for update
11741193
const updatedOrder = { ...filledOrder, type: ORDER_TYPES.SPREAD, state: ORDER_STATES.VIRTUAL, size: 0, orderId: null };
@@ -1178,6 +1197,12 @@ class OrderManager {
11781197
this.logger.log(`Converted order ${filledOrder.id} to SPREAD`, 'debug');
11791198
}
11801199

1200+
// Accumulate BTS fees if applicable
1201+
if (hasBtsPair && btsFeesDuedThisFill > 0) {
1202+
this.funds.btsFeesOwed += btsFeesDuedThisFill;
1203+
this.logger.log(`Total BTS fees owed accumulated: ${this.funds.btsFeesOwed.toFixed(8)} BTS`, 'info');
1204+
}
1205+
11811206
// Apply proceeds directly to accountTotals so availability reflects fills immediately (no waiting for a chain refresh)
11821207
if (!this.accountTotals) {
11831208
this.accountTotals = { buy: 0, sell: 0, buyFree: 0, sellFree: 0 };
@@ -1528,7 +1553,22 @@ class OrderManager {
15281553
// Calculate new order size using proceeds from this rotation cycle
15291554
// Prefer pendingProceeds so sizes match the just-filled funds, not unrelated chainFree
15301555
const side = targetType === ORDER_TYPES.BUY ? 'buy' : 'sell';
1531-
const availableFunds = this.funds.pendingProceeds?.[side] ?? 0;
1556+
let availableFunds = this.funds.pendingProceeds?.[side] ?? 0;
1557+
1558+
// Reduce available funds by BTS blockchain fees if BTS is in the pair
1559+
// BTS fees are deducted from the buy side (quote asset) when BTS is assetB
1560+
// BTS fees are deducted from the sell side (base asset) when BTS is assetA
1561+
const hasBtsPair = this.config.assetA === 'BTS' || this.config.assetB === 'BTS';
1562+
if (hasBtsPair && this.funds.btsFeesOwed > 0) {
1563+
const isBtsOnThisSide = (side === 'buy' && this.config.assetB === 'BTS') || (side === 'sell' && this.config.assetA === 'BTS');
1564+
if (isBtsOnThisSide) {
1565+
const feesOwedThisSide = Math.min(this.funds.btsFeesOwed, availableFunds);
1566+
availableFunds -= feesOwedThisSide;
1567+
this.funds.btsFeesOwed -= feesOwedThisSide;
1568+
this.logger.log(`Reducing ${side}-side order by BTS fees: ${feesOwedThisSide.toFixed(8)} BTS. Remaining fees: ${this.funds.btsFeesOwed.toFixed(8)} BTS`, 'info');
1569+
}
1570+
}
1571+
15321572
const orderCount = Math.min(ordersToProcess.length, eligibleSpreadOrders.length);
15331573
const fundsPerOrder = orderCount > 0 ? availableFunds / orderCount : 0;
15341574

modules/order/utils.js

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,232 @@ const derivePrice = async (BitShares, symA, symB, mode) => {
643643

644644
};
645645

646+
// ---------------------------------------------------------------------------
647+
// Fee caching and retrieval
648+
// ---------------------------------------------------------------------------
649+
650+
/**
651+
* Cache for storing fee information for all assets
652+
* Structure: {
653+
* assetSymbol: {
654+
* assetId: string,
655+
* precision: number,
656+
* marketFee: { basisPoints: number, percent: number },
657+
* takerFee: { percent: number } | null,
658+
* maxMarketFee: { raw: number, float: number }
659+
* },
660+
* BTS: { blockchain fees - see below }
661+
* }
662+
*/
663+
let feeCache = {};
664+
665+
/**
666+
* Initialize and cache fees for all assets from bots.json configuration
667+
* Also includes BTS for blockchain fees (maker/taker order creation/cancel)
668+
*
669+
* @param {Array} botsConfig - Array of bot configurations from bots.json
670+
* @param {object} BitShares - BitShares library instance for fetching asset data
671+
* @returns {Promise<object>} The populated fee cache
672+
*/
673+
async function initializeFeeCache(botsConfig, BitShares) {
674+
if (!botsConfig || !Array.isArray(botsConfig)) {
675+
throw new Error('botsConfig must be an array of bot configurations');
676+
}
677+
if (!BitShares || !BitShares.db) {
678+
throw new Error('BitShares library instance with db methods required');
679+
}
680+
681+
// Extract unique asset symbols from bot configurations
682+
const uniqueAssets = new Set(['BTS']); // Always include BTS for blockchain fees
683+
684+
for (const bot of botsConfig) {
685+
if (bot.assetA) uniqueAssets.add(bot.assetA);
686+
if (bot.assetB) uniqueAssets.add(bot.assetB);
687+
}
688+
689+
// Fetch and cache fees for each asset
690+
for (const assetSymbol of uniqueAssets) {
691+
try {
692+
if (assetSymbol === 'BTS') {
693+
// Special handling for BTS - fetch blockchain operation fees
694+
feeCache.BTS = await _fetchBlockchainFees(BitShares);
695+
} else {
696+
// Fetch market fees for other assets
697+
feeCache[assetSymbol] = await _fetchAssetMarketFees(assetSymbol, BitShares);
698+
}
699+
} catch (error) {
700+
console.error(`Error caching fees for ${assetSymbol}:`, error.message);
701+
// Continue with other assets even if one fails
702+
}
703+
}
704+
705+
return feeCache;
706+
}
707+
708+
/**
709+
* Get cached fees for a specific asset
710+
* Useful for checking if cache has been initialized
711+
*
712+
* @param {string} assetSymbol - Asset symbol (e.g., 'IOB.XRP', 'TWENTIX', 'BTS')
713+
* @returns {object|null} Fee data if cached, null otherwise
714+
*/
715+
function getCachedFees(assetSymbol) {
716+
return feeCache[assetSymbol] || null;
717+
}
718+
719+
/**
720+
* Clear the fee cache (useful for testing or refreshing)
721+
*/
722+
function clearFeeCache() {
723+
feeCache = {};
724+
}
725+
726+
/**
727+
* Get total fees (blockchain + market) for a filled order amount
728+
*
729+
* @param {string} assetSymbol - Asset symbol (e.g., 'IOB.XRP', 'TWENTIX', 'BTS')
730+
* @param {number} assetAmount - Amount of asset to calculate fees for
731+
* @returns {number} Total fee amount in the asset's native units
732+
* For BTS: blockchain fees only (creation 10% + update)
733+
* For market assets: market fee on the amount
734+
*/
735+
function getAssetFees(assetSymbol, assetAmount) {
736+
const cachedFees = feeCache[assetSymbol];
737+
738+
if (!cachedFees) {
739+
throw new Error(`Fees not cached for ${assetSymbol}. Call initializeFeeCache first.`);
740+
}
741+
742+
assetAmount = Number(assetAmount);
743+
if (!Number.isFinite(assetAmount) || assetAmount < 0) {
744+
throw new Error(`Invalid assetAmount: ${assetAmount}`);
745+
}
746+
747+
// Special handling for BTS (blockchain fees only)
748+
if (assetSymbol === 'BTS') {
749+
const orderCreationFee = cachedFees.limitOrderCreate.bts;
750+
const orderUpdateFee = cachedFees.limitOrderUpdate.bts;
751+
const makerNetFee = orderCreationFee * 0.1; // 10% of creation fee after 90% refund
752+
return makerNetFee + orderUpdateFee;
753+
}
754+
755+
// Handle regular assets - deduct market fee from the amount received
756+
const marketFeePercent = cachedFees.marketFee?.percent || 0;
757+
const marketFeeAmount = (assetAmount * marketFeePercent) / 100;
758+
759+
// Return amount after market fees are deducted
760+
return assetAmount - marketFeeAmount;
761+
}
762+
763+
/**
764+
* Internal function to fetch blockchain operation fees
765+
*/
766+
async function _fetchBlockchainFees(BitShares) {
767+
try {
768+
const globalProps = await BitShares.db.getGlobalProperties();
769+
const currentFees = globalProps.parameters.current_fees;
770+
771+
const fees = {
772+
limitOrderCreate: { raw: 0, satoshis: 0, bts: 0 },
773+
limitOrderCancel: { raw: 0, satoshis: 0, bts: 0 },
774+
limitOrderUpdate: { raw: 0, satoshis: 0, bts: 0 }
775+
};
776+
777+
// Extract fees from the parameters array
778+
for (let i = 0; i < currentFees.parameters.length; i++) {
779+
const param = currentFees.parameters[i];
780+
if (!param || param.length < 2) continue;
781+
782+
const opCode = param[0];
783+
const feeData = param[1];
784+
785+
if (opCode === 1 && feeData.fee !== undefined) {
786+
// Operation 1: limit_order_create
787+
fees.limitOrderCreate = {
788+
raw: feeData.fee,
789+
satoshis: Number(feeData.fee),
790+
bts: blockchainToFloat(feeData.fee, 5)
791+
};
792+
} else if (opCode === 2 && feeData.fee !== undefined) {
793+
// Operation 2: limit_order_cancel
794+
fees.limitOrderCancel = {
795+
raw: feeData.fee,
796+
satoshis: Number(feeData.fee),
797+
bts: blockchainToFloat(feeData.fee, 5)
798+
};
799+
} else if (opCode === 77 && feeData.fee !== undefined) {
800+
// Operation 77: limit_order_update
801+
fees.limitOrderUpdate = {
802+
raw: feeData.fee,
803+
satoshis: Number(feeData.fee),
804+
bts: blockchainToFloat(feeData.fee, 5)
805+
};
806+
}
807+
}
808+
809+
return fees;
810+
} catch (error) {
811+
throw new Error(`Failed to fetch blockchain fees: ${error.message}`);
812+
}
813+
}
814+
815+
/**
816+
* Internal function to fetch market fees for a specific asset
817+
*/
818+
async function _fetchAssetMarketFees(assetSymbol, BitShares) {
819+
try {
820+
const assetData = await BitShares.db.lookupAssetSymbols([assetSymbol]);
821+
if (!assetData || !assetData[0]) {
822+
throw new Error(`Asset ${assetSymbol} not found`);
823+
}
824+
825+
const assetId = assetData[0].id;
826+
const fullAssets = await BitShares.db.getAssets([assetId]);
827+
if (!fullAssets || !fullAssets[0]) {
828+
throw new Error(`Could not fetch full data for ${assetSymbol}`);
829+
}
830+
831+
const fullAsset = fullAssets[0];
832+
const options = fullAsset.options || {};
833+
834+
const marketFeeBasisPoints = options.market_fee_percent || 0;
835+
const marketFeePercent = marketFeeBasisPoints / 100;
836+
837+
// Extract taker fee from extensions
838+
let takerFeePercent = null;
839+
if (options.extensions && typeof options.extensions === 'object') {
840+
if (options.extensions.taker_fee_percent !== undefined) {
841+
const value = Number(options.extensions.taker_fee_percent || 0);
842+
takerFeePercent = value / 100;
843+
}
844+
}
845+
846+
// Check if taker_fee_percent exists directly in options
847+
if (takerFeePercent === null && options.taker_fee_percent !== undefined) {
848+
const value = Number(options.taker_fee_percent || 0);
849+
takerFeePercent = value / 100;
850+
}
851+
852+
return {
853+
assetId: assetId,
854+
symbol: assetSymbol,
855+
precision: fullAsset.precision,
856+
marketFee: {
857+
basisPoints: marketFeeBasisPoints,
858+
percent: marketFeePercent
859+
},
860+
takerFee: takerFeePercent !== null ? { percent: takerFeePercent } : null,
861+
maxMarketFee: {
862+
raw: options.max_market_fee || 0,
863+
float: blockchainToFloat(options.max_market_fee || 0, fullAsset.precision)
864+
},
865+
issuer: fullAsset.issuer
866+
};
867+
} catch (error) {
868+
throw new Error(`Failed to fetch market fees for ${assetSymbol}: ${error.message}`);
869+
}
870+
}
871+
646872
// ---------------------------------------------------------------------------
647873
// Exports
648874
// ---------------------------------------------------------------------------
@@ -679,5 +905,11 @@ module.exports = {
679905
lookupAsset,
680906
deriveMarketPrice,
681907
derivePoolPrice,
682-
derivePrice
908+
derivePrice,
909+
910+
// Fee caching and retrieval
911+
initializeFeeCache,
912+
getCachedFees,
913+
clearFeeCache,
914+
getAssetFees
683915
};

0 commit comments

Comments
 (0)