Розробка системи учету позицій крипто-фонду
Учет позицій крипто-фонду — це не "подивився баланс на etherscan". Це багатовимірна задача: позиції розповсюджені по десяткам гаманців та кількох блокчейнах, значна частина заблокована у DeFi-протоколах, вартість деривативних позицій змінюється нелінійно, і все це потрібно агрегувати у реальному часі з коректним розрахунком P&L та обліком історичної вартості для податкової звітності.
Готові рішення типу Zapper або DeBank добрі для фізичних осіб. Для фонду з кастодіальними вимогами, аудитом та власною методологією розрахунку — потрібна кастомна система.
Архітектура системи
Система складається з чотирьох шарів:
Data Layer: Blockchain Nodes / RPC Providers
↓
Indexing: Position Fetchers (per protocol)
↓
Accounting: Valuation Engine + P&L Calculator
↓
Reporting: API + Dashboard + Audit Export
Data Layer: джерела даних
Для кожної позиції потрібні різні джерела:
Spot holdings (токени на гаманцях): eth_call до ERC-20 balanceOf або агрегація через Moralis/Alchemy getTokenBalances. Для мультичейну — паралельні запити до кожної цепочки.
DeFi позиції — найскладніша частина. У кожного протоколу своя модель:
| Протокол | Тип позиції | Спосіб отримання |
|---|---|---|
| Uniswap V3 | LP position (NFT) | NonfungiblePositionManager.positions(tokenId) |
| Aave V3 | Lending/borrowing | aaveDataProvider.getUserReserveData() |
| Compound V3 | Supply/borrow | comet.balanceOf() + comet.borrowBalanceOf() |
| Curve | LP shares | pool.balances() + gauge.balanceOf() |
| GMX | Perp positions | Reader.getPositions() |
| Lido | stETH | stETH.balanceOf() (rebasing!) |
Locked/vested позиції: vesting контракти, gauge locks (Curve/Velodrome veNFT), staking з lockup. Ці позиції мають майбутню вартість з часовим дисконтом — потрібно вирішити як їх учитувати в NAV.
Position Fetcher: рівень абстракції
Кожен протокол реалізує інтерфейс PositionFetcher:
interface Position {
protocol: string
chain: string
type: 'spot' | 'lp' | 'lending' | 'borrowing' | 'staking' | 'perp'
tokens: TokenAmount[] // складові позиції
valueUsd: Decimal // поточна вартість
metadata: Record<string, unknown>
}
interface PositionFetcher {
protocol: string
chains: string[]
fetch(wallet: string, blockNumber?: number): Promise<Position[]>
}
Приклад Uniswap V3 fetcher:
class UniswapV3Fetcher implements PositionFetcher {
protocol = 'uniswap-v3'
chains = ['ethereum', 'arbitrum', 'optimism', 'polygon', 'base']
async fetch(wallet: string, blockNumber?: number): Promise<Position[]> {
const nfpm = new ethers.Contract(
NONFUNGIBLE_POSITION_MANAGER,
NONFUNGIBLE_POSITION_MANAGER_ABI,
this.provider
)
const overrides = blockNumber ? { blockTag: blockNumber } : {}
// Отримання всіх LP NFT через Transfer events (від genesis до сейчас)
const balance = await nfpm.balanceOf(wallet, overrides)
const tokenIds = await Promise.all(
Array.from({ length: Number(balance) }, (_, i) =>
nfpm.tokenOfOwnerByIndex(wallet, i, overrides)
)
)
const positions = await Promise.all(
tokenIds.map(async (id) => {
const pos = await nfpm.positions(id, overrides)
return this.decodePosition(id, pos, wallet, blockNumber)
})
)
return positions.filter(p => p.tokens[0].amount > 0n || p.tokens[1].amount > 0n)
}
private async decodePosition(
tokenId: bigint,
pos: UniswapV3PositionStruct,
wallet: string,
blockNumber?: number
): Promise<Position> {
// Розрахунок amounts з liquidity + tickLower + tickUpper + currentSqrtPriceX96
const [token0Amount, token1Amount] = getAmountsForLiquidity(
await this.getCurrentSqrtPrice(pos.poolAddress, blockNumber),
pos.tickLower,
pos.tickUpper,
pos.liquidity
)
// Накопилені комісії (unclaimed)
const [fees0, fees1] = await this.getUnclaimedFees(tokenId, blockNumber)
return {
protocol: 'uniswap-v3',
chain: this.chain,
type: 'lp',
tokens: [
{ token: pos.token0, amount: token0Amount + fees0 },
{ token: pos.token1, amount: token1Amount + fees1 },
],
valueUsd: await this.calculateUsdValue(pos.token0, token0Amount, pos.token1, token1Amount),
metadata: { tokenId: tokenId.toString(), fee: pos.fee, tickRange: [pos.tickLower, pos.tickUpper] },
}
}
}
Оцінка вартості (Valuation Engine)
Джерела цін
Для точної оцінки потрібна ієрархія джерел цін:
- DEX on-chain price (Uniswap V3 TWAP) — маніпульовано при короткому вікні, надійно при 30-хвилинному TWAP
- CEX агрегатор (CoinGecko, CoinMarketCap API) — затримка до 5 хвилин, немає для exotics
- Pyth Network / Chainlink — on-chain oracle, для позицій які вже on-chain
- Manual pricing — для ілліквідних токенів, OTC позицій
class ValuationEngine {
private priceCache = new Map<string, { price: Decimal; timestamp: number }>()
async getPrice(tokenAddress: string, chain: string): Promise<Decimal> {
const cacheKey = `${chain}:${tokenAddress}`
const cached = this.priceCache.get(cacheKey)
// Кеш 60 секунд для активних рыночних годин
if (cached && Date.now() - cached.timestamp < 60_000) {
return cached.price
}
const price = await this.fetchPriceWithFallback(tokenAddress, chain)
this.priceCache.set(cacheKey, { price, timestamp: Date.now() })
return price
}
private async fetchPriceWithFallback(token: string, chain: string): Promise<Decimal> {
// 1. Спроба отримати ціну із Uniswap V3 TWAP (30 хв)
try {
return await this.getUniswapTWAP(token, chain, 1800)
} catch {}
// 2. CoinGecko API
try {
return await this.getCoinGeckoPrice(token, chain)
} catch {}
// 3. Остання відома ціна з БД позначена як stale
const lastKnown = await this.db.getLastKnownPrice(token, chain)
if (lastKnown) {
this.emitAlert(`STALE_PRICE: ${token} on ${chain}`)
return lastKnown.price
}
throw new Error(`Cannot price token ${token} on ${chain}`)
}
}
Rebasing токени
Особливий випадок — rebasing токени: stETH, aUSDC, aETH. Їхній баланс змінюється кожний блок без Transfer подій. Либо враховувати реальний баланс через balanceOf замість Transfer-based accounting, либо конвертувати у wrapped версію (wstETH замість stETH).
P&L розрахунок та cost basis
Для податкової звітності та performance reporting потрібно відслідковувати історичну вартість позицій. Два основних методи:
FIFO (First In, First Out) — для кожної продажі берем вартість найранніше купленої порції. Вимагає повної історії придбань.
Average Cost Basis — середня вартість всіх куплених токенів. Простіше у розрахунку, менш вигідна податково на зростаючому ринку.
class CostBasisTracker {
// Лоти: кожна покупка — окремий лот з датою та ціною
async recordAcquisition(
wallet: string,
token: string,
amount: Decimal,
priceUsd: Decimal,
txHash: string,
timestamp: Date
): Promise<void> {
await this.db.query(`
INSERT INTO cost_basis_lots
(wallet, token, amount, price_usd, cost_basis_usd, acquired_at, tx_hash)
VALUES ($1, $2, $3, $4, $3 * $4, $5, $6)
`, [wallet, token, amount, priceUsd, timestamp, txHash])
}
async calculateRealizedPnl(
wallet: string,
token: string,
soldAmount: Decimal,
soldPriceUsd: Decimal
): Promise<{ realizedPnl: Decimal; costBasis: Decimal }> {
// FIFO: берем лоти в порядку придбання
const lots = await this.db.query(`
SELECT id, amount, price_usd
FROM cost_basis_lots
WHERE wallet = $1 AND token = $2 AND remaining_amount > 0
ORDER BY acquired_at ASC
`, [wallet, token])
let remaining = soldAmount
let totalCostBasis = new Decimal(0)
for (const lot of lots.rows) {
if (remaining.lte(0)) break
const consumed = Decimal.min(remaining, new Decimal(lot.remaining_amount))
totalCostBasis = totalCostBasis.plus(consumed.mul(lot.price_usd))
remaining = remaining.minus(consumed)
await this.updateLotRemainder(lot.id, consumed)
}
const proceeds = soldAmount.mul(soldPriceUsd)
return {
realizedPnl: proceeds.minus(totalCostBasis),
costBasis: totalCostBasis,
}
}
}
Snapshots та історичний NAV
Для аудиту та investor reporting потрібні історичні snapshots. Система має бути здатна відтворити портфель на будь-яку історичну дату:
CREATE TABLE portfolio_snapshots (
id BIGSERIAL PRIMARY KEY,
snapshot_at TIMESTAMPTZ NOT NULL,
wallet VARCHAR(42) NOT NULL,
block_number BIGINT NOT NULL, -- якір до конкретного блоку
total_value_usd NUMERIC(20, 6) NOT NULL,
positions JSONB NOT NULL, -- серіалізований список позицій
prices_used JSONB NOT NULL -- ціни, використані для оцінки
);
-- Індекс для швидкого пошуку NAV за період
CREATE INDEX idx_snapshots_wallet_time
ON portfolio_snapshots (wallet, snapshot_at DESC);
Historical block snapshot вимагає archive node (або провайдера з archive access). Alchemy та QuickNode надають історичний стан через eth_call з blockTag.
Алерти та операційний мониторинг
Система учету позицій без алертів — це система, яку перестають перевіряти. Обов'язкові тригери:
- Зміна позиції > N% за 15 хвилин (неочікувана активність)
- Ціна токена недоступна > 5 хвилин (
STALE_PRICE) - Розходження між on-chain балансом та обліком > 0.1%
- Ліквідація позиції у lending протоколі
- Uniswap V3 позиція вийшла за межі діапазону
Стек: Node.js / Go бекенд, PostgreSQL з TimescaleDB, Redis для кеша цін, Grafana дашборд, PagerDuty для критичних алертів. Archive RPC через Alchemy/QuickNode для історичних даних.







