Skip to content

adr: 0017 title: "AMM permanent price impact — trades leave a decaying footprint" status: accepted implementation-status: implemented date: 2026-06-03 implemented-date: 2026-06-03 implemented-in: - ftl-backend feat/amm-permanent-impact deciders: [@amalkrsihna] affects-specs: [amm-pricing, cfd-pricing-and-wallet] affects-code: - ftl-backend/internal/redis/lua/position_open.lua - ftl-backend/internal/redis/lua/position_close.lua - ftl-backend/internal/instrument/pricing.go - ftl-backend/internal/instrument/service.go - ftl-backend/internal/sportmonks/noise.go - ftl-backend/internal/sportmonks/postmatch.go - ftl-backend/internal/flusher/flusher.go - ftl-backend/migrations/000038_add_permanent_impact.up.sql - ftl-backend/migrations/000039_bump_k_mod_default.up.sql supersedes: null superseded-by: null


ADR-0017: AMM permanent price impact — trades leave a decaying footprint

Context

Play-testing showed the price chart "snaps back" to its base range after every Buy or Sell is closed — a completed trade left no lasting mark. Three causes compounded:

  1. Closes exactly reverse opens. The CFD price is base_price + k_mod · net_position_imbalance. position_open.lua adds sign · lot_shares to the imbalance; position_close.lua subtracts exactly the same amount. A buy→close round-trip returns the imbalance — and the price — to its starting point. There is no memory of the trade.
  2. k_mod was too small to see. At the default 0.01, one lot (CONTRACT_SIZE = 5 shares) moved price by 0.05 on a ₹100–500 instrument — below the chart's resolution. Even 100 lots moved it ~₹5.
  3. Noise ignored the AMM. The synthetic-noise loop anchored its jitter on live_base_price/base_price and ignored net_position_imbalance entirely, so between ticks the chart was visually pulled back to base even while a position was open.

The product intent is that buying pushes a player's price up and it stays up (with minimal decay), and selling pushes it down — a real, demand-driven market.

Decision

Introduce a per-instrument permanent_impact accumulator that records a fraction of every closed trade's directional pressure and enters the price formula alongside the live imbalance:

price = base_price + k_mod · (net_position_imbalance + permanent_impact)

Concrete changes:

  1. Schema. Migration 000038 adds instruments.permanent_impact NUMERIC(14,4) NOT NULL DEFAULT 0 (additive). Migration 000039 bumps the k_mod default 0.01 → 0.05 and migrates rows still on the old default, so one lot now moves price ~₹0.25 — visible against the 0.2 % noise band.
  2. Accumulate on close. position_close.lua adds sign · lot_shares · PERM_FRACTION (PERM_FRACTION = 0.30) to permanent_impact, clamped by a floor of -(base_price/k_mod)·0.5 − new_imbalance so a heavy net-short can never drive the combined term — and thus the price — to or below zero.
  3. Price everywhere reads it. position_open.lua (open quote + post-open price), position_close.lua (close price + equity walk), the Go instrument enrichment (ComputeCFDPrice), and the FT snapshot in postmatch.go all use k_mod · (imbalance + permanent_impact).
  4. Noise anchors on the AMM price. noise.go reads net_position_imbalance, k_mod, permanent_impact and oscillates its jitter around base + k_mod·(imbalance + permanent_impact) instead of raw base.
  5. Durable + decaying. The flusher persists permanent_impact (and net_position_imbalance) to PG so the footprint survives a cold restart, and runs a decay pass every PERM_IMPACT_DECAY_EVERY_N flusher ticks shrinking each instrument's impact by PERM_IMPACT_DECAY_RATE (defaults: 600 ticks ≈ 60 s, 0.5 %/pass ⇒ ~2.3 h half-life), flooring to exactly 0 near zero.
  6. Reset at full-time. postmatch.go clears permanent_impact to 0 in Redis and PG, because the new base_price already folds in the match outcome via EWMA — carrying old trade pressure forward would double-count.

Consequences

  • Positive: A closed trade leaves a visible, lasting footprint (e.g. one long lot retains k_mod · lot_shares · 0.30 = 0.05 · 5 · 0.30 = ₹0.075 after close) that decays over hours. The chart reflects accumulated demand instead of reverting instantly.
  • Positive: The noise no longer fights the AMM — jitter rides on top of the trade-shifted price.
  • Positive: All tuning levers (PERM_FRACTION in Lua, k_mod default, decay cadence/rate via env) are adjustable without structural change.
  • Risk — k_mod retiming: open positions are priced from k_mod read at close time, so bumping the default changes the unrealized PnL of any already-open position on the next equity walk. Mitigation: run migration 000039 between matches when imbalance ≈ 0 and few positions are open.
  • Risk — flusher write expansion: the instrument UPDATE now also writes net_position_imbalance + permanent_impact; a Redis hash eviction would reset live imbalance to 0 on the next flush. This risk already existed for wallets and is accepted for the tournament scope.

Alternatives considered

Alternative A: Don't reverse imbalance on close (leave the open's push in place)

Make position_close.lua skip the imbalance decrement so the open's price impact simply stays.

Rejected. The imbalance must reflect currently open interest for margin and equity math to be correct — leaving a closed position's shares in net_position_imbalance would corrupt the unrealized-PnL walk for everyone else holding that instrument. A separate permanent_impact field keeps live margin accounting honest while still recording a footprint.

Alternative B: Just raise k_mod and accept the snap-back

Bump k_mod so trades are visible, but keep closes fully reversing.

Rejected. A bigger k_mod makes the open visible but the close still erases it — the chart would jump up on buy and snap back on close, which is exactly the "trades leave no trace" complaint, only louder. Permanent impact is what gives the chart memory; the k_mod bump only makes that memory legible.

Alternative C: Never decay permanent_impact

Let the footprint persist indefinitely.

Rejected. Without decay, months of trading would warp base prices arbitrarily far from the player's actual form-driven value, and the FT EWMA (which already encodes performance) would be swamped. A multi-hour half-life plus the FT reset keeps the footprint meaningful in-session without permanent distortion.

Verification

  • Unit: ComputeCFDPrice formula table; a buy→close round-trip leaves the price strictly above the pre-trade base by k_mod · lot_shares · PERM_FRACTION; flusher decay shrinks toward 0, floors to exactly 0, and re-queues dirty instruments (miniredis).
  • Migrations applied on dev02: permanent_impact column present, k_mod default = 0.05, 780 rows migrated off 0.01. go build/vet/gofmt clean.
  • Live buy→close→footprint integration is covered by the unit tests plus the already-proven imbalance-reversal mechanism (this session validated that open/close move net_position_imbalance correctly); a full live-window run was deferred because no real Sportmonks match was live and the local replay match has no tradeable instruments for its teams.