Skip to content

Commit 70543e9

Browse files
committed
fix: use net inventory lots in trade profitability analyzer
Buy lots now store net receives (baseAmount − marketFeeReal), so inventory matching reflects what the account actually held. The fee allocation denominator is also net, matching the net lot basis. ## Key changes - `analysis/trade_profitability.ts`: lot amount and fee alloc denominator use net base; restored `effPrice` on lot/realized PnL for correct cost basis; added EffBuy column to `--trades` detail output; skip fills with unresolved fee precision instead of silently setting fee to 0 - `tests/test_trade_profitability_fees.ts`: updated all fee test assertions to net-lot math (fee denominator, unmatched sell base, net position) ## Testing - `node --import tsx tests/test_trade_profitability_fees.ts` — all pass - full `npm test` suite — clean
1 parent 8b9d315 commit 70543e9

3 files changed

Lines changed: 62 additions & 72 deletions

File tree

analysis/trade_profitability.ts

Lines changed: 25 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ interface RealizedPnl {
151151
amount: number;
152152
pnl: number;
153153
pnlPct: number;
154+
effPrice: number;
154155
marketFeeEntry: number;
155156
marketFeeExit: number;
156157
feeBts: number;
@@ -441,8 +442,8 @@ function classifyFills(fills: FillRecord[], filterAsset: string | null): { trade
441442
let marketFeeAsset: string;
442443

443444
const feePrec = getPrec(f.fee.asset_id);
444-
const feeOk = feePrec !== undefined && !isNaN(f.fee.amount);
445-
const feeReal = feeOk ? f.fee.amount / Math.pow(10, feePrec) : 0;
445+
if (feePrec === undefined || isNaN(f.fee.amount)) { skipped++; continue; }
446+
const feeReal = f.fee.amount / Math.pow(10, feePrec);
446447

447448
if (pAsset === BTS_ID && rAsset !== BTS_ID) {
448449
direction = 'buy';
@@ -527,29 +528,6 @@ function analyzePair(trades: TradeFill[], matchMode: 'fifo' | 'sequential' = 'se
527528
const totalBuyQuote = buys.reduce((s, t) => s + t.quoteAmount, 0);
528529
const totalSellQuote = sells.reduce((s, t) => s + t.quoteAmount, 0);
529530

530-
/**
531-
* Compute the effective price net of market fees.
532-
*
533-
* Per BitShares core (db_market.cpp fill_limit_order):
534-
* order_receives = receives - pay_market_fees(seller, receives_asset, receives, is_maker)
535-
*
536-
* The `fee` field in fill_order_operation is the market fee deducted from receives.
537-
* For buys (pays=quote, receives=base): fee is in base → effective buy price = quote / (base - fee)
538-
* For sells (pays=base, receives=quote): fee is in quote → effective sell price = (quote - fee) / base
539-
*/
540-
function effectiveTradePrice(t: TradeFill): number {
541-
if (t.marketFeeReal <= 0) return t.price;
542-
if (t.direction === 'buy') {
543-
const netBase = t.baseAmount - t.marketFeeReal;
544-
if (netBase <= 0) return t.price;
545-
return t.quoteAmount / netBase;
546-
} else {
547-
const netQuote = t.quoteAmount - t.marketFeeReal;
548-
if (netQuote <= 0) return t.price;
549-
return netQuote / t.baseAmount;
550-
}
551-
}
552-
553531
// Merge all trades chronologically.
554532
// FIFO: buys add to queue, sells consume oldest lots (queue front).
555533
// Sequential: buys add to queue, sells consume newest lots (queue back / LIFO).
@@ -558,17 +536,18 @@ function analyzePair(trades: TradeFill[], matchMode: 'fifo' | 'sequential' = 'se
558536
const realizedPnls: RealizedPnl[] = [];
559537
let unmatchedSellBase = 0;
560538

561-
// First pass: match with gross prices to get base PnL, then with effective
562-
// prices to get market-fee-adjusted PnL.
563539
for (const trade of all) {
564540
const grossPrice = trade.price;
565-
const effPrice = effectiveTradePrice(trade);
566541

567542
if (trade.direction === 'buy') {
543+
// Enter lot with net amount (gross receives minus market fee on receives)
544+
const lotAmount = trade.baseAmount - trade.marketFeeReal;
545+
if (lotAmount < 1e-12) continue;
546+
const lotEffPrice = trade.quoteAmount / lotAmount;
568547
inventory.push({
569-
amount: trade.baseAmount,
548+
amount: lotAmount,
570549
grossPrice: grossPrice,
571-
effPrice: effPrice,
550+
effPrice: lotEffPrice,
572551
time: trade.time,
573552
entryOrderId: trade.orderId,
574553
entryIsMaker: trade.isMaker,
@@ -591,6 +570,7 @@ function analyzePair(trades: TradeFill[], matchMode: 'fifo' | 'sequential' = 'se
591570
amount: matched,
592571
pnl: grossPnl,
593572
pnlPct: grossPnlPct,
573+
effPrice: lot.effPrice,
594574
marketFeeEntry: 0,
595575
marketFeeExit: 0,
596576
feeBts: 0,
@@ -625,7 +605,7 @@ function analyzePair(trades: TradeFill[], matchMode: 'fifo' | 'sequential' = 'se
625605
// ─── Market fee allocation (aggregated per order) ──────────────────────
626606
// A limit order may be filled across multiple fill_order_operations with
627607
// the same orderId. Aggregate all fills' market fees per order, then
628-
// allocate pro-rata using the fill's total acquired amount as denominator.
608+
// allocate pro-rata using the fill's net acquired amount as denominator.
629609
// (Market fee is paid on acquisition; the portion tied to unsold inventory
630610
// is not yet realised.)
631611
const entryOrderFees = new Map<string, { feeInQuote: number; totalAcquired: number }>();
@@ -638,7 +618,7 @@ function analyzePair(trades: TradeFill[], matchMode: 'fifo' | 'sequential' = 'se
638618
const netBase = t.baseAmount - t.marketFeeReal;
639619
e.feeInQuote += netBase > 0 ? t.marketFeeReal * (t.quoteAmount / netBase) : 0;
640620
}
641-
e.totalAcquired += t.baseAmount;
621+
e.totalAcquired += t.baseAmount - t.marketFeeReal;
642622
entryOrderFees.set(t.orderId, e);
643623
}
644624

@@ -679,15 +659,15 @@ function analyzePair(trades: TradeFill[], matchMode: 'fifo' | 'sequential' = 'se
679659
const xTotal = exitTotalMatched.get(r.exitOrderId) || 1;
680660
r.feeBts = BLOCKCHAIN_FEE_PER_FILL * (r.amount / eTotal) + BLOCKCHAIN_FEE_PER_FILL * (r.amount / xTotal);
681661
r.pnlNet -= r.feeBts;
682-
// Use effective cost basis (gross cost + entry market fee)
683-
const costBasis = r.buyPrice * r.amount + r.marketFeeEntry;
662+
const costBasis = r.effPrice * r.amount;
684663
r.pnlNetPct = costBasis > 0 ? (r.pnlNet / costBasis) * 100 : 0;
685664
}
686665

666+
const totalBuyBaseNet = buys.reduce((s, t) => s + t.baseAmount - t.marketFeeReal, 0);
687667
const totalRealizedPnl = realizedPnls.reduce((s, r) => s + r.pnl, 0);
688668
const totalMarketFees = realizedPnls.reduce((s, r) => s + r.marketFeeEntry + r.marketFeeExit, 0);
689669
const totalRealizedPnlNet = realizedPnls.reduce((s, r) => s + r.pnlNet, 0);
690-
const netPosition = totalBuyBase - totalSellBase;
670+
const netPosition = totalBuyBaseNet - totalSellBase;
691671
const baseAsset = trades[0]?.baseAsset ?? '';
692672
const quoteAsset = trades[0]?.quoteAsset ?? '';
693673

@@ -789,10 +769,13 @@ function printSummary(pairs: PairAnalysis[], accountId: string, start: string, e
789769
}
790770
}
791771
console.log(` Note: PnL uses gross prices. Net PnL deducts market fees (charged by`);
792-
console.log(` asset issuer on receives) and blockchain operation fees (BTS`);
793-
console.log(` per limit_order_create). Market fees are converted to quote asset.`);
794-
console.log(` If inventory predates the window or crosses asset pairs,`);
795-
console.log(` the matched lots may not reflect true trade economics.`);
772+
console.log(` asset issuer on receives, both issuer and network portions) and`);
773+
console.log(` blockchain operation fees (BTS per limit_order_create). Market`);
774+
console.log(` fees are converted to quote asset. Buy lots are entered at net`);
775+
console.log(` receives (gross minus buy-side market fee) so inventory matching`);
776+
console.log(` reflects what the account actually held. If inventory predates the`);
777+
console.log(` window or crosses asset pairs, the matched lots may not reflect`);
778+
console.log(` true trade economics.`);
796779
console.log('');
797780
}
798781

@@ -805,14 +788,15 @@ function printPnlDetail(pairs: PairAnalysis[]) {
805788
console.log(` ── ${pairLabel} — Realized PnL Detail`);
806789
console.log('');
807790

808-
const hdr = ' # Buy Price Sell Price Amount PnL PnL% MktFee OpFeeBTS Net PnL Net% Legs Entry Time Exit Time';
791+
const hdr = ' # Buy Price EffBuy Sell Price Amount PnL PnL% MktFee OpFeeBTS Net PnL Net% Legs Entry Time Exit Time';
809792
console.log(hdr);
810793
console.log(' ' + '─'.repeat(hdr.length - 1));
811794

812795
for (let i = 0; i < pair.realizedPnls.length; i++) {
813796
const r = pair.realizedPnls[i];
814797
const idx = String(i + 1).padStart(2);
815798
const bp = fmt(r.buyPrice, 8).padStart(11);
799+
const ep = fmt(r.effPrice, 8).padStart(11);
816800
const sp = fmt(r.sellPrice, 8).padStart(11);
817801
const amt = fmt(r.amount, 4).padStart(9);
818802
const pnlStr = fmt(r.pnl, 6).padStart(9);
@@ -824,7 +808,7 @@ function printPnlDetail(pairs: PairAnalysis[]) {
824808
const mk = (r.entryIsMaker ? 'M' : 'T') + '/' + (r.exitIsMaker ? 'M' : 'T') + ' ';
825809
const et = (r.entryTime || '').slice(0, 22).padEnd(22);
826810
const xt = (r.exitTime || '').slice(0, 22).padEnd(22);
827-
console.log(` ${idx} ${bp} ${sp} ${amt} ${pnlStr} ${pctStr} ${mktFeeStr} ${feeStr} ${netStr} ${netPctStr} ${mk} ${et} ${xt}`);
811+
console.log(` ${idx} ${bp} ${ep} ${sp} ${amt} ${pnlStr} ${pctStr} ${mktFeeStr} ${feeStr} ${netStr} ${netPctStr} ${mk} ${et} ${xt}`);
828812
}
829813
console.log('');
830814
}

market_adapter/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ To keep asymmetric bounds disabled:
6969
node dexbot white --no-asymmetric-bounds
7070
```
7171

72+
Remove stale whitelist entries (bots no longer in `bots.json`):
73+
74+
```bash
75+
node dexbot white --prune
76+
```
77+
7278
### 3. Start DEXBot2
7379

7480
Start DEXBot2 normally. The bot runtime launches the adapter when needed.
@@ -271,6 +277,7 @@ Dry-run log lines include `[DRY RUN]` or `[suppressed, dry-run]`.
271277
| Generate whitelist | `node dexbot white` |
272278
| Opt new whitelist entries into dynamic weights | `node dexbot white --dynamic-weight` |
273279
| Generate AMA-only entries without range scaling | `node dexbot white --no-asymmetric-bounds` |
280+
| Prune stale whitelist entries (bots removed from bots.json) | `node dexbot white --prune` |
274281
| Probe public CEX availability | `tsx market_adapter/inputs/fetch_cex_synthetic_data.ts --exchange auto --check-only` |
275282
| Seed synthetic cross candles | `tsx market_adapter/inputs/fetch_cex_synthetic_data.ts --exchange auto --bot-key <bot-key>` |
276283
| Run one adapter cycle | `tsx market_adapter/market_adapter.ts --once` |

tests/test_trade_profitability_fees.ts

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,19 @@ function testBuySideMarketFee() {
9797
const result = analyzePair(trades, 'fifo');
9898

9999
assert.strictEqual(result.realizedPnls.length, 1, 'should have 1 realized PnL');
100-
// Gross PnL = (11 - 10) * 10 = 10 (using gross prices)
101-
assert.strictEqual(result.totalRealizedPnl, 10, 'gross PnL = 10');
100+
// Lot = 10 - 0.1 = 9.9 (net receives). Only 9.9 can be matched.
101+
// Gross PnL = (11 - 10) * 9.9 = 9.9
102+
assert.strictEqual(result.totalRealizedPnl, 9.9, 'gross PnL = 9.9');
102103

103-
// Effective buy price = 100 / (10 - 0.1) = 100 / 9.9 ≈ 10.10101
104-
// Market-fee-adjusted PnL = (11 - 10.10101) * 10 ≈ 8.9899
105-
// marketFeeDrag = 10 - 8.9899 = 1.0101
106-
const expectedMarketFee = 10 - (11 - 100 / 9.9) * 10;
104+
// Market fee = 0.1 base × (100 BTS / 9.9 net base) — fully realised on full sale
105+
const expectedMarketFee = 0.1 * (100 / 9.9);
107106
assert.ok(Math.abs(result.totalMarketFees - expectedMarketFee) < 0.001,
108107
`market fee drag ≈ ${expectedMarketFee.toFixed(4)}, got ${result.totalMarketFees.toFixed(4)}`);
109108

109+
// 0.1 base units were never actually received — they show as unmatched
110+
assert.ok(Math.abs(result.unmatchedSellBase - 0.1) < 0.0001,
111+
'~0.1 base unmatched (never received), got ' + result.unmatchedSellBase);
112+
110113
// Verify marketFeeEntry on the realized PnL
111114
const r = result.realizedPnls[0];
112115
assert.ok(r.marketFeeEntry > 0, 'entry market fee should be > 0');
@@ -177,18 +180,15 @@ function testBothSidesMarketFees() {
177180
const result = analyzePair(trades, 'fifo');
178181

179182
assert.strictEqual(result.realizedPnls.length, 1);
180-
// Gross PnL
181-
const grossPnl = (grossSellPrice - grossBuyPrice) * amount;
182-
assert.strictEqual(result.totalRealizedPnl, grossPnl, 'gross PnL');
183-
184-
// Effective buy price = 100 / (10 - 0.1) = 100 / 9.9 ≈ 10.101010...
185-
const effBuy = (amount * grossBuyPrice) / (amount - buyFee);
186-
// Effective sell price = (110 - 1.1) / 10 = 10.89
187-
const effSell = (amount * grossSellPrice - sellFee) / amount;
188-
// Adjusted PnL = (10.89 - 10.10101) * 10 = 7.89899...
189-
const adjPnl = (effSell - effBuy) * amount;
190-
// Market fee drag = grossPnl - adjPnl
191-
const expectedFees = grossPnl - adjPnl;
183+
// Net lot = 10 - 0.1 = 9.9, matched = min(10, 9.9) = 9.9
184+
const netAmount = amount - buyFee;
185+
const grossPnl = (grossSellPrice - grossBuyPrice) * netAmount;
186+
assert.strictEqual(result.totalRealizedPnl, grossPnl, 'gross PnL on net lot');
187+
188+
// Full buy fee realised on full sale; sell fee prorated by net/gross
189+
const expectedFeeEntry = buyFee * (amount * grossBuyPrice / (amount - buyFee));
190+
const expectedFeeExit = sellFee * (netAmount / amount);
191+
const expectedFees = expectedFeeEntry + expectedFeeExit;
192192

193193
assert.ok(Math.abs(result.totalMarketFees - expectedFees) < 0.001,
194194
`market fees ≈ ${expectedFees.toFixed(4)}, got ${result.totalMarketFees.toFixed(4)}`);
@@ -241,14 +241,13 @@ function testPartialFillWithFees() {
241241

242242
assert.strictEqual(result.realizedPnls.length, 1, 'one matched lot');
243243
assert.strictEqual(result.realizedPnls[0].amount, 5, 'matched 5 units');
244-
// marketFeeEntry should be proportional: feeBaseShare = 0.1 * (5/10) = 0.05
245-
// feeInQuote = 0.05 * (100 / 9.9) ≈ 0.50505...
246-
const expectedEntryFee = 0.05 * (100 / 9.9);
244+
// marketFeeEntry = feeInQuote_total × (5 / 9.9 net lot)
245+
const expectedEntryFee = (0.1 * (100 / 9.9)) * (5 / 9.9);
247246
assert.ok(Math.abs(result.realizedPnls[0].marketFeeEntry - expectedEntryFee) < 0.001,
248247
`proportional entry fee ≈ ${expectedEntryFee.toFixed(6)}, got ${result.realizedPnls[0].marketFeeEntry.toFixed(6)}`);
249248
assert.strictEqual(result.realizedPnls[0].marketFeeExit, 0, 'no exit fee');
250249
assert.strictEqual(result.unmatchedSellBase, 0, 'no unmatched');
251-
assert.strictEqual(result.netPosition, 5, '5 units remaining in inventory');
250+
assert.strictEqual(result.netPosition, 4.9, '4.9 units remaining (net of buy fee)');
252251
}
253252

254253
// ─── Test: Multiple fills with fees (FIFO vs Sequential) ──────────────────
@@ -291,8 +290,8 @@ function testMultipleFillsFifo() {
291290
// Gross PnL = (11 - 10.5) * 3 = 1.5
292291
assert.strictEqual(resultFifo.realizedPnls[1].pnl, 1.5,
293292
'lot 2 gross PnL = 1.5');
294-
// Market fee entry = 0.05 * (3/5) * (52.5 / 4.95) ≈ 0.31818
295-
const feeEntry2 = 0.05 * (3 / 5) * effBuy2;
293+
// Market fee entry = 0.05 * (52.5/4.95) * (3/4.95) — denominator is net lot
294+
const feeEntry2 = 0.05 * (52.5 / (5 - 0.05)) * (3 / (5 - 0.05));
296295
assert.ok(Math.abs(resultFifo.realizedPnls[1].marketFeeEntry - feeEntry2) < 0.001,
297296
`lot 2 entry fee ≈ ${feeEntry2.toFixed(6)}, got ${resultFifo.realizedPnls[1].marketFeeEntry.toFixed(6)}`);
298297
// Net PnL = grossPnl - marketFeeEntry - blockchainFee
@@ -333,14 +332,14 @@ function testMakerTakerFee() {
333332

334333
const result = analyzePair(trades, 'fifo');
335334

336-
// Maker fee on buy: 0.05 base → effective buy price = 100 / (10-0.05)
337-
const effBuy = 100 / (10 - 0.05);
338-
// Taker fee on sell: 2.2 BTS → effective sell price = (110-2.2)/10 = 10.78
339-
const effSell = (110 - 2.2) / 10;
340-
const adjPnl = (effSell - effBuy) * 10;
341-
const expectedFees = 10 - adjPnl;
335+
// Lot = 10 - 0.05 = 9.95, matched = 9.95
336+
const netAmount = 10 - 0.05;
337+
// Full buy fee realised on full sale
338+
const expectedFeeEntry = 0.05 * (100 / 9.95);
339+
const expectedFeeExit = 2.2 * (9.95 / 10);
340+
const expectedFees = expectedFeeEntry + expectedFeeExit;
342341

343-
assert.ok(Math.abs(result.totalMarketFees - expectedFees) < 0.001,
342+
assert.ok(Math.abs(result.totalMarketFees - expectedFees) < 0.01,
344343
`maker/taker fees ≈ ${expectedFees.toFixed(4)}, got ${result.totalMarketFees.toFixed(4)}`);
345344
}
346345

0 commit comments

Comments
 (0)