nautechsystems/nautilus_trader · critical

Duplicate fill event for position {position_id}: {}

Error message

Duplicate fill event for position {position_id}: {}

What it means

This error is thrown by `load_position` in the SQL cache layer when rebuilding a `Position` from persisted fill events. After constructing the position from the first fill, each remaining fill's `trade_id` is checked against `position.trade_ids()`; if a fill with a trade_id already applied to the position appears again, the load aborts. It indicates the persisted position event stream contains duplicate fills, so the position cannot be reconstructed deterministically.

Source

Thrown at crates/infrastructure/src/sql/queries.rs:960

                .map_err(|e| anyhow::anyhow!("Failed to decode position replay state: {e}"));
        }

        let fills = Self::load_position_events(pool, position_id).await?;
        let Some((first_fill, remaining_fills)) = fills.split_first() else {
            return Ok(None);
        };
        let Some(instrument) = Self::load_instrument(pool, &first_fill.instrument_id).await? else {
            log::error!(
                "Instrument not found for position {position_id}: {}",
                first_fill.instrument_id
            );
            return Ok(None);
        };

        let mut position = Position::new(&instrument, first_fill.clone());
        for fill in remaining_fills {
            if position.trade_ids().contains(&fill.trade_id) {
                anyhow::bail!(
                    "Duplicate fill event for position {position_id}: {}",
                    fill.trade_id
                );
            }
            position.apply(fill);
        }

        Ok(Some(position))
    }

    /// Loads and replays all `Position` entries via the provided `pool`.
    ///
    /// # Errors
    ///
    /// Returns an error if loading position IDs or replaying any position fails.
    pub async fn load_positions(pool: &PgPool) -> anyhow::Result<Vec<Position>> {
        let position_ids: Vec<PositionId> = sqlx::query(
            r#"

View on GitHub (pinned to 18893faf8b)

Solutions

  1. Inspect the persisted position events for the given position_id and delete the duplicate fill rows (dedupe on trade_id).
  2. Find the writer that inserted the fill twice (usually a retry after timeout without idempotency) and make the INSERT idempotent, e.g. a UNIQUE constraint on (account_id, instrument_id, trade_id) with ON CONFLICT DO NOTHING.
  3. If the duplicate came from an adapter re-emitting fills, guard upstream where fills are persisted (skip events whose trade_id already exists).

Example fix

// before: blindly inserting each fill event
sqlx::query("INSERT INTO position_events (...) VALUES (...)")
    .execute(pool).await?;

// after: make persistence idempotent on trade_id
sqlx::query(
    "INSERT INTO position_events (...) VALUES (...) ON CONFLICT (trade_id) DO NOTHING",
)
.execute(pool).await?;
Defensive patterns

Strategy: try-catch

Try / catch

match PositionQueries::load_position(&pool, position_id).await {
    Ok(Some(pos)) => pos,
    Ok(None) => return /* position absent */,
    Err(e) if e.to_string().contains("Duplicate fill event") => {
        // quarantine the position rows and re-ingest from source of truth
        log::error!("corrupt position stream: {e}");
        /* re-ingest */
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Calling `PositionQueries::load_position(pool, position_id)` where the rows fetched for that position (beyond the first fill) contain a `trade_id` already present in the position's trade_ids — i.e. the `position` events table has a duplicate fill/`position_opened`/`position_modified` event with the same trade_id.

Common situations: Double-writing the same fill event to Postgres (e.g. a retried INSERT without deduplication, or two writers persisting the same trade), a partially-failed batch insert that was re-run, or a corrupted/manual copy of rows between environments.

Understand the failure class

Background: Database query failed: Internal Server Error 500s wrapping SQL, Prisma, and connection failures — what to check first — this error's family across 16 libraries.

Related errors


AI-assisted analysis of nautechsystems/nautilus_trader@18893faf8b (2026-09-08). Data as JSON: /api/errors/860491a0b9a605aa. Report an issue: GitHub.