Skip to content

Market Pricing

Status: 🚧 Partial β€” Core supply/demand engine (formula, clamps, spread floor, class premium, history/trend) is live and wired via lazy advance-on-read … Β· ⚠︎ contains code↔spec divergence (impl audit 2026-06-16)

Purpose

Every station (port) maintains independent buy/sell prices for the commodities it trades. Prices are derived from local supply and demand and are recomputed on three events: a transaction, a production tick, and an admin/ARIA query for a fresh snapshot. The system also emits price-history snapshots (so trends, volatility, and price-alert evaluation can run downstream) and is the structural source of all arbitrage profit in the game.

Inputs

The pricing engine reads: - Station.commodities β€” JSONB map of {commodity_name: {quantity, capacity, base_price, production_rate, price_variance, current_price}}. - Station.station_class and Station.type β€” define which commodities the station buys vs sells (see ../FEATURES/economy/trading.md). - Player.faction_reputation (via FactionService) β€” modifies the player-facing price. - Player.personal_reputation (via PersonalReputationService) β€” small markup/discount for extreme alignments. - Player.military_rank β€” adds a flat trading bonus (rank.trading_bonus percent off). - Region.tax_rate β€” regional tax applied at transaction time. - Existing MarketPrice row (if any) β€” for trend calculation.

The system fires on: - Transaction (buy/sell) β€” triggers pricing recompute and history update for the affected commodity. - Production tick β€” periodic regen of Station.commodities[*].quantity; price recompute follows. - Snapshot read β€” realtime_market_service.get_market_snapshot(commodity) and ARIA market intelligence trigger a refresh + cache. - Price-alert evaluation β€” admin-operations alerts; on each price recompute the evaluator reads the latest MarketPrice and matches it against the station-scoped alert thresholds admins configure.

Process

Pricing formula

1. supply_ratio = clamp(quantity / capacity, 0.0, 1.0)
2. midpoint     = base_price Γ— (1.5 - supply_ratio)
                  # supply 0.0 β†’ 1.5Γ— base (scarcity)
                  # supply 1.0 β†’ 0.5Γ— base (surplus)
3. npc_nudge    = 1.0 + clamp((npc_restock_demand - 1.0) Γ— 0.15, -0.15, +0.15)
   midpoint     = midpoint Γ— npc_nudge   # bounded Β±15% NPC-restock-demand nudge
                  # npc_restock_demand default 1.0 β†’ no nudge
4. sell_price   = midpoint Γ— 1.15      # station sells TO player
   buy_price    = midpoint Γ— 0.85      # station buys FROM player
5. clamp both to COMMODITY_PRICE_RANGES[commodity]
6. if buy_price >= sell_price: buy_price = sell_price - 1   # spread floor

Per-commodity price clamps

Commodity min max Notes
ore 15 45
organics 8 25
gourmet_food 30 70
fuel 20 60
equipment 50 120
exotic_technology 150 300
luxury_goods 75 200
colonists 30 80
precious_metals 80 180 Per ADR-0062 E-D1 β€” slotted between equipment and exotic_technology; canonical mining drop per ../FEATURES/economy/mining.md.

Player-facing price modifiers (applied at transaction time, on top of station price)

Per ADR-0062 E-D3, the canonical multiplicative composition order, general β†’ specific:

final_price = station_price
  Γ— faction_reputation_multiplier   (0.85 to 1.50 β€” Exalted to Public Enemy)
  Γ— personal_reputation_multiplier  (0.90 to 1.20 β€” Legendary to Villain)
  Γ— (1 - rank.trading_bonus / 100)  (Recruit 0% β†’ Fleet Admiral 50%)
  Γ— (1 + region.tax_rate)             # flat regional revenue, treasury per ADR-0059
  Γ— (1 + region.tariff_rate)          # commerce policy, capped per E-F2 below
  Γ— (1 + station.price_adjustment_lever)   # Β±10%, skipped on same-owner trades per E-F1

Direction of personal_reputation_multiplier flips for sells (a Legendary player gets 10% better sell price too). region.tax_rate and region.tariff_rate are separate concepts: tax is a flat percentage applied to all in-region commerce (regional treasury revenue per ADR-0059 treasury entries); tariff is a per-trade modifier on top.

Same-owner lever skip (per ADR-0062 E-F1): when buyer and seller stations share an owner (or controlling_team_id), the price_adjustment_lever is ignored. Same-owner internal transfers run at the rep + tax + tariff modifiers only. Closes the cycle-between-own-stations arbitrage; cross-owner trades unaffected.

Tariff sliding cap (per ADR-0062 E-F2): the maximum region.tariff_rate scales by station count in the region:

Stations in region Max tariff
< 3 5%
3–5 15%
β‰₯ 6 25% (default)

The tariff-set endpoint validates against the live station count. Existing tariffs above the new cap clamp on next read. Region operators must invest in commerce density before they can dial tariff up β€” closes the captive-Frontier-player extraction vector.

Transaction settlement

buy (station β†’ player):
  validate player is docked at this station, in same sector
  recompute final_price as above
  total = final_price * units
  if player.credits < total: 400 Insufficient credits
  if station.commodities[c].quantity < units: 400 Insufficient stock
  player.credits -= total
  player.cargo[c] += units
  station.commodities[c].quantity -= units
  emit MarketTransaction row
  trigger update_market_prices(station_id)
  emit "market_update" event for that station+commodity

sell (player β†’ station):
  symmetric: cargo decremented, station stock incremented, credits credited.

update_market_prices(station_id)

Per commodity on the station: 1. Recompute sell_price, buy_price via the dynamic formula. 2. Read existing MarketPrice row. 3. Roll old β†’ previous: previous_buy_price ← buy_price, previous_sell_price ← sell_price. 4. Compute price_trend = (new_mid - old_mid) / old_mid (signed ratio). 5. Update quantity, supply_level = q/cap, demand_level = 1 - q/cap, volatility = price_variance / 100. 6. Persist station JSONB (current_price = (buy+sell)//2) and MarketPrice row. 7. Set Station.last_market_update = now.

Eager market bootstrap on station creation

πŸ“ Design-only. Per ADR-0053 WR14. When a Station row is created β€” by worldgen Phase 10, Phase 11 anchor injection, or runtime construction (region-funded TradeDocks, etc.) β€” the same transaction also creates MarketPrice rows for every commodity the station's class trades:

def create_station_with_market_bootstrap(station_data):
    with transaction():
        station = Station.create(**station_data)
        for commodity in CLASS_TRADE_PATTERN[station.station_class]:
            base_price = Resource.get(commodity).base_price
            base_quantity = CLASS_BASELINE_QUANTITY[station.station_class][commodity]
            MarketPrice.create(
                station_id=station.id,
                commodity_type=commodity,
                buy_price=base_price * BASELINE_BUY_SPREAD,
                sell_price=base_price * BASELINE_SELL_SPREAD,
                quantity=base_quantity,
                supply_level=baseline_supply_level(commodity),
                demand_level=baseline_demand_level(commodity),
                last_updated=now(),
            )

No lazy initialization. Every Station row is born with its market book populated. This eliminates "first trader gets undefined behavior" bugs and means market queries on freshly-constructed stations always return valid data.

Failure mode: if any MarketPrice insert fails, the entire transaction rolls back. There's no partial-creation state where a Station exists without its market book.

The class trade pattern (CLASS_TRADE_PATTERN[station.station_class]) is the canonical Class 0–11 trade-pattern table per ../FEATURES/economy/trading.md. For TradeDocks (Tier-A and Tier-B), the trade pattern is the broader TradeDock surface per ../FEATURES/economy/tradedock-shipyard.md.

Per-station rate limit (1-second min-interval)

Per ADR-0051 SK30 (which also closes WR13), per-station price updates rate-limit at 1-second minimum interval. All transactions and production-tick events within a 1-second window batch into a single end-of-window recomputation:

def maybe_recompute_price(station, trigger_kind):
    if station.last_price_recomputed_at and
       (now() - station.last_price_recomputed_at).seconds < 1:
        # Within window β€” flag, no recomputation yet
        station.pending_price_recomputation = True
        return
    # Window elapsed β€” recompute including any pending batch
    update_market_prices(station.id)
    station.last_price_recomputed_at = now()
    station.pending_price_recomputation = False

A per-station scheduler tick every 1 second flushes any station with pending_price_recomputation = True whose 1-second window just elapsed. Soft natural batching; no complex scheduler. Composes with the per-region planetary tick (per ./planetary-production-tick.md) β€” both are per-region scheduled work.

New columns on Station for the rate limit: last_price_recomputed_at: DateTime nullable, pending_price_recomputation: Boolean default false.

Production tick

for each commodity in station.commodities:
  rate = commodity.production_rate
  if rate <= 0 or quantity >= capacity: continue
  new_qty = min(capacity, quantity + rate)
  commodity.quantity = new_qty

if any change: update_market_prices(station_id)

Tick cadence: hourly per station, scheduled by the realtime market service or an external scheduler.

CLASS_BASELINE_PRODUCTION_RATES

Per ADR-0062 E-I1, baseline production by (station_class, commodity). Units per hour. Class 1–2 are raw materials; class 3 adds finished goods; classes 4–5 scale by multiplier and add late-game commodities:

Station class Commodity Rate (units/hour)
1 ore 50
1 organics 30
1 fuel 20
2 ore 80
2 organics 50
2 fuel 35
2 equipment 10
3 ore 120
3 organics 80
3 fuel 60
3 equipment 25
3 precious_metals 5
3 gourmet_food 15
4 (all class-3 commodities) 1.5Γ— class 3; adds exotic_technology @ 8/hr, luxury_goods @ 12/hr
5 (all class-4 commodities) 2.0Γ— class 3; adds colonists @ 4/hr

Numbers are Launch balance targets β€” live tuning happens in this table, not in code.

Player-trade vs NPC-restock demand split

Per ADR-0062 E-V4, Station carries two distinct demand fields:

  • player_demand_score β€” derived from player buy/sell activity. Drives the player-facing market UI's demand indicator and ARIA market summaries.
  • npc_restock_demand β€” derived from NPC trader orders. Drives NPC restocking logic only; never blended into the player signal.

Player-facing market UI shows only player_demand_score. NPC trader manipulation cannot skew the player signal because they touch different fields. Migration: existing demand_score reads route to player_demand_score; the NPC logic gets a new field populated by the NPC trader service.

Price-alert evaluation cycle

Price alerts are an admin-operations construct, not a player feature. Each PriceAlert row is station-scoped β€” (station_id, commodity, alert_type, threshold_value, severity, message) β€” and is created and managed by economy admins through the admin economy routes. alert_type is one of price_spike, price_drop, high_volume, or low_supply; severity is low/medium/high/critical.

  • On each update_market_prices call, after persistence, the alert evaluator:
  • Loads active alerts matching (station_id, commodity).
  • For each alert, compares the new market value against threshold_value.
  • On a crossing, raises (or updates) the station-scoped PriceAlert row with its current_value, severity, and message for the admin economy view; auto_resolve alerts clear themselves once the value returns within resolve_threshold.

An admin acknowledges an alert (acknowledged_by references the admin User, acknowledged_at stamped) and may resolve or delete it. Alerts are surfaced and managed only on the admin economy surface; there is no per-player alert ownership and no per-player alert notification.

Snapshot stream

realtime_market_service aggregates the latest MarketPrice rows per commodity into a MarketSnapshot (best buy / best sell / depth / trend / volatility / AI prediction). Snapshots are cached in Redis (TTL 1 s, coherent with the 1 s minimum snapshot interval) and pushed on the market topic of the realtime bus.

Outputs / state changes

Per transaction: - Player.credits adjusted. - Player.current_ship.cargo adjusted (cargo JSONB). - Station.commodities[c].quantity adjusted. - Station.commodities[c].current_price adjusted (display midpoint). - Station.last_market_update set. - MarketPrice row upserted. - MarketTransaction row inserted (full ledger). - Region.treasury (if station is region-owned) credited any tax.

Events emitted: - market_update β€” sector + market topic; carries new buy/sell, trend, supply/demand levels. - transaction_completed β€” unicast to the player's session.

Price-alert crossings do not emit a player-facing event; they raise station-scoped PriceAlert rows on the admin economy surface (see Price-alert evaluation cycle).

Invariants

  1. sell_price > buy_price for every commodity at every station (the 15% spread plus the floor rule).
  2. Station stock is never negative: 0 ≀ quantity ≀ capacity.
  3. Commodity prices clamp inside COMMODITY_PRICE_RANGES[*] even at extremes.
  4. A transaction either fully settles or fully rolls back β€” credits and cargo move together.
  5. Player.credits β‰₯ 0 at all times.
  6. Station.commodities JSONB is mutated through flag_modified so SQLAlchemy persists the diff.
  7. MarketPrice.previous_* always reflects the immediately prior tick (no skipping).
  8. The pricing recompute runs after stock mutation in transaction settlement, so the next reader sees the new equilibrium.

Failure modes

Mode Target handling
Race: two players buy the last unit Pre-flight stock check inside a row-locked transaction; second transaction returns 400 Insufficient stock.
Station class not selling/buying this commodity Trade rejected at TradingService.can_player_trade; no price compute.
Player not docked or not in same sector Rejected at can_player_trade; no state changes.
Negative production_rate config Tick skips the commodity (treated as no production).
capacity = 0 / missing Sanitized to 1 to avoid div-by-zero; price clamps still apply.
Station JSONB drift (manual admin edit) update_market_prices re-derives prices from quantity / capacity; MarketPrice history gets a single anomalous tick β€” flagged via volatility.
Alert flap (price oscillating around threshold) Alert holds a cooldown_seconds field; re-fires only after cooldown expires.
Snapshot cache stale during outage Redis miss falls through to live DB read; latency spikes but correctness preserved.
Region tax misconfigured Default tax = 0; mis-set rate clamped to [0, 0.5].

Source map

Concern Path (target)
Pricing formula + spec clamps services/gameserver/src/services/trading_service.py:calculate_dynamic_price
Market price write + history services/gameserver/src/services/trading_service.py:update_market_prices
Production tick services/gameserver/src/services/trading_service.py:tick_production
Trade eligibility services/gameserver/src/services/trading_service.py:can_player_trade
Realtime market snapshot services/gameserver/src/services/realtime_market_service.py
Market prediction services/gameserver/src/services/market_prediction_engine.py
Faction modifier services/gameserver/src/services/faction_service.py
Personal-reputation modifier services/gameserver/src/services/personal_reputation_service.py
Trade API services/gameserver/src/api/routes/trading.py
Models services/gameserver/src/models/station.py, market_transaction.py, resource.py
Price-alert evaluator services/gameserver/src/services/price_alert_service.py
Price-alert admin management services/gameserver/src/api/routes/admin_economy.py