Skip to content
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
71 changes: 71 additions & 0 deletions src/lib/trade-executor/models/position-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tradingPositionSchema } from '../schemas/position';
import { createTradingPositionInfo } from './position-info';

type TradeExecutionInput = z.input<typeof tradeExecutionSchema>;
type PositionStatisticsInput = z.input<typeof positionStatisticsSchema>;
type PositionInput = z.input<typeof tradingPositionSchema>;

const POSITION_OPEN_TS = 1735689600;
Expand Down Expand Up @@ -77,6 +78,18 @@ const stats = [
}
].map((s) => positionStatisticsSchema.parse(s));

function positionStat(stat: Partial<PositionStatisticsInput> = {}) {
return positionStatisticsSchema.parse({
calculated_at: POSITION_OPEN_TS + HOUR,
last_valuation_at: POSITION_OPEN_TS + HOUR,
profitability: 0,
profit_usd: 0,
quantity: 100,
value: 100,
...stat
});
}

describe('TradingPositionInfo object with no stats', () => {
const position = tradingPositionSchema.parse(rawPosition);
const positionInfo = createTradingPositionInfo(position);
Expand Down Expand Up @@ -139,6 +152,64 @@ describe('closed position with stats entries', () => {
});
});

describe('dust position warning', () => {
test('detects an explicit dust note', () => {
const position = tradingPositionSchema.parse({
...rawPosition,
notes: 'Auto-closed as dust by correct-accounts (equity=$0.0106)'
});

expect(createTradingPositionInfo(position).isDustPositionWarning).toBe(true);
});

test('detects a tiny residual quantity in final stats', () => {
const position = tradingPositionSchema.parse({
...rawPosition,
closed_at: POSITION_OPEN_TS + 3 * HOUR,
last_token_price: 0.95
});
const residualStats = [
positionStat({
calculated_at: POSITION_OPEN_TS + 2 * HOUR,
last_valuation_at: POSITION_OPEN_TS + 2 * HOUR,
quantity: 1334,
value: 1272
}),
positionStat({
calculated_at: POSITION_OPEN_TS + 4 * HOUR,
last_valuation_at: POSITION_OPEN_TS + 4 * HOUR,
quantity: 1.0023875,
value: 0
})
];

expect(createTradingPositionInfo(position, residualStats).isDustPositionWarning).toBe(true);
});

test('does not flag a normal fully closed zero-quantity position', () => {
const position = tradingPositionSchema.parse({
...rawPosition,
closed_at: POSITION_OPEN_TS + 3 * HOUR
});
const closedStats = [
positionStat({
calculated_at: POSITION_OPEN_TS + 2 * HOUR,
last_valuation_at: POSITION_OPEN_TS + 2 * HOUR,
quantity: 100,
value: 100
}),
positionStat({
calculated_at: POSITION_OPEN_TS + 4 * HOUR,
last_valuation_at: POSITION_OPEN_TS + 4 * HOUR,
quantity: 0,
value: 0
})
];

expect(createTradingPositionInfo(position, closedStats).isDustPositionWarning).toBe(false);
});
});

describe('position with frozen_at value', () => {
const frozenPosition = tradingPositionSchema.parse({
...rawPosition,
Expand Down
40 changes: 40 additions & 0 deletions src/lib/trade-executor/models/position-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import type { TimeBucket } from '$lib/schemas/utility';
import { type TradeDirection, TradeDirections } from './trade-info';
import { isNumber } from '$lib/helpers/formatters';

const DUST_POSITION_VALUE_THRESHOLD_USD = 2;
const MIN_RESIDUAL_QUANTITY = 1e-12;

export const createTradingPositionInfo = <T extends TradingPosition>(base: T, stats: PositionStatistics[] = []) => ({
...base,

Expand Down Expand Up @@ -135,6 +138,43 @@ export const createTradingPositionInfo = <T extends TradingPosition>(base: T, st
return Math.max(...this.stats.map((s) => s.value));
},

get hasDustNote() {
const hasPositionDustNote = /dust/i.test(this.notes ?? '');
const hasTradeDustNote = this.trades.some((trade) => /dust/i.test(trade.notes ?? ''));
return hasPositionDustNote || hasTradeDustNote;
},

get latestQuantity() {
return this.latestStats?.quantity;
},

get latestRecordedValue() {
return this.latestStats?.value;
},

get latestNominalValue() {
if (isNumber(this.latestQuantity) && isNumber(this.last_token_price)) {
return Math.abs(this.latestQuantity * this.last_token_price);
}
},

get hasResidualQuantity() {
return isNumber(this.latestQuantity) && Math.abs(this.latestQuantity) > MIN_RESIDUAL_QUANTITY;
},

get isDustPositionWarning() {
if (this.hasDustNote) return true;
if (!this.hasResidualQuantity) return false;

const latestRecordedValue = isNumber(this.latestRecordedValue) ? Math.abs(this.latestRecordedValue) : undefined;
const latestNominalValue = this.latestNominalValue;

return (
(isNumber(latestRecordedValue) && latestRecordedValue <= DUST_POSITION_VALUE_THRESHOLD_USD) ||
(isNumber(latestNominalValue) && latestNominalValue <= DUST_POSITION_VALUE_THRESHOLD_USD)
);
},

get quantityAtOpen() {
const quantity = this.stats[0]?.quantity;
return quantity && Math.abs(quantity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
</div>
</svelte:fragment>
</PageHeading>

{#if position.isDustPositionWarning}
<div class="dust-position-warning">
<Alert size="sm" status="warning">The metrics might be off because the position is too small (dusty).</Alert>
</div>
{/if}
</Section>

<Section class={position.failedOpen || position.frozen ? 'has-error' : ''}>
Expand Down Expand Up @@ -169,5 +175,9 @@
gap: 0.75rem;
justify-content: flex-end;
}

.dust-position-warning {
margin-top: var(--space-lg);
}
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
</script>

<section class="trade-table">
<h2>Trades</h2>
<h2>Rebalances</h2>
<DataTable {tableViewModel} targetableRows size="sm" />
</section>

Expand Down
Loading