На GPU-фермах майню токены GONKA. Железо добывает монеты, я их продаю на SafeTrade и Hex OTC — биржах, где торгуются альткоины. Но когда смотришь на историю сделок, видишь только цену продажи. А сколько стоило намайнить эти токены? Без этого P&L превращается в гадание на кофейной гуще.

Задача выглядела просто: для каждой сделки нужно знать cost basis — себестоимость одного GNK. Тогда profit = (цена продажи - cost) × количество, и можно считать margin. Но когда начал копать, оказалось, что никто не связывал торговые данные с майнинговыми. Биржевые сделки жили отдельно, эпохи блокчейна — отдельно, хостинг GPU — вообще в конфиге.

Эпохи как единица времени

В блокчейне GONKA время делится на эпохи — каждая длится примерно 25 часов. В конце эпохи сеть распределяет награды. У меня уже была модель EpochInfo в базе transactions:

class EpochInfo(models.Model):
    epoch_id = models.IntegerField(primary_key=True)
    start_date = models.DateTimeField()
    duration_seconds = models.IntegerField()

Эпоха стала естественной единицей для расчётов. Правило простое: если сделка произошла в эпохе N, используй cost из эпохи N-1. Почему предыдущая? Потому что токены, которые я продаю сегодня, были намайнены вчера.

Cost basis = затраты / награды

Формула себестоимости:

def calculate_cost_for_epoch(self, user_id, epoch_index):
    epoch = EpochInfo.objects.get(epoch_id=epoch_index)
    duration_hours = Decimal(epoch.duration_seconds) / Decimal(3600)

    total_cost = Decimal('0')
    total_gnk = Decimal('0')

    for address in user_addresses:
        hourly_rate = Decimal(str(settings.address_rental_costs[address]))
        total_cost += hourly_rate * duration_hours

        rewards = BlockchainReward.objects.filter(
            address=address,
            epoch_number=epoch_index
        )
        total_gnk += sum(r.rewarded_coins_gnk for r in rewards)

    return (total_cost / total_gnk).quantize(Decimal('0.0001'))

Часовая ставка хостинга умножается на длительность эпохи — получаю затраты. Делю на сумму наград за эту эпоху — получаю cost per GNK.

Hex OTC и реверс-инжиниринг API

SafeTrade давал CSV через админку — там всё просто. А вот Hex OTC оказался интереснее. API нестандартный, документации нет. Пришлось запустить DevTools в браузере, посмотреть, какие запросы шлёт их фронтенд, и повторить логику:

class HexClient:
    def fetch_trades(self, user_id, start_date, end_date):
        response = self.session.get(
            f"{self.base_url}/otc/trades",
            params={
                'user': user_id,
                'from': start_date.isoformat(),
                'to': end_date.isoformat()
            }
        )
        return self._parse_response(response.json())

Формат ответа был странным — вложенные массивы, метки времени в миллисекундах, суммы в wei. Написал парсер, который раскладывает всё по полям модели ExchangeTrade.

TradeCostService и Celery pipeline

Центральный сервис — TradeCostService. Он умеет три вещи:

  1. Найти эпоху для сделки (trade в N → cost из N-1)
  2. Посчитать cost для эпохи
  3. Обновить cost_per_gnk у всех сделок в этой эпохе
def sync_trade_costs(self, user_id, epoch_index):
    """Пересчитывает cost для всех сделок в эпохе"""
    cost = self.calculate_cost_for_epoch(user_id, epoch_index)

    trades = ExchangeTrade.objects.filter(
        user_id=user_id,
        traded_at__range=epoch_date_range,
        is_cost_manual=False  # не трогаем ручные правки
    )

    trades.update(cost_per_gnk=cost)

Флаг is_cost_manual критичен. Иногда парсер ошибается — неправильно распознал сумму или дату. Менеджеры правят через TradeEditModal во фронтенде, и эти правки должны сохраняться. Если is_cost_manual=True, автопересчёт пропускает такую сделку.

Для исторических данных написал задачу backfill_trade_costs:

@shared_task
def backfill_trade_costs(user_id):
    """Пересчёт cost для всех сделок юзера"""
    epochs = EpochInfo.objects.all().order_by('epoch_id')
    service = TradeCostService()

    for epoch in epochs:
        service.sync_trade_costs(user_id, epoch.epoch_id)

PATCH API для ручных корректировок

@api_view(['PATCH'])
def update_trade(request, trade_id):
    trade = get_object_or_404(ExchangeTrade, id=trade_id)

    if 'cost_per_gnk' in request.data:
        trade.cost_per_gnk = request.data['cost_per_gnk']
        trade.is_cost_manual = True

    trade.save()
    return Response(TradeSerializer(trade).data)

Любое изменение cost через API проставляет флаг. Это защищает от случайной перезаписи при следующем sync.

Margin и profit как properties

class ExchangeTrade(models.Model):
    amount_gonka = models.DecimalField(max_digits=20, decimal_places=8)
    price_usd = models.DecimalField(max_digits=20, decimal_places=8)
    cost_per_gnk = models.DecimalField(null=True)
    is_cost_manual = models.BooleanField(default=False)

    @property
    def profit_per_gnk(self):
        if self.cost_per_gnk:
            return self.price_usd - self.cost_per_gnk
        return None

    @property
    def margin_percent(self):
        if self.profit_per_gnk and self.price_usd > 0:
            return float((self.profit_per_gnk / self.price_usd) * 100)
        return None

Теперь фронтенд может показывать profit и margin без дополнительных вычислений. Serializer просто включает эти поля в JSON.

Результат

16 января 2026 года залил 18 коммитов. Начал утром с пустого TradeCostService, закончил вечером с работающим пайплайном. Теперь каждая сделка знает свой cost basis. В админке вижу margin по каждой операции. Автоматический sync обновляет данные после завершения эпохи, ручные правки не перезаписываются.

В статистике появились реальные цифры: средний margin по SafeTrade — 18%, по Hex OTC — 12%. Некоторые сделки оказались убыточными — продал дешевле, чем стоило намайнить. Раньше это было незаметно, потому что не было точки отсчёта.

Следующий шаг — уведомления в Telegram, когда margin падает ниже порога. Но это уже другая история.