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. (Re-verified 2026-08-21 vs origin/feat
46bce720.) The only ๐ design-only item on this page is the WR14 eager-market-bootstrap (ยง below, already correctly marked from tonight's ADR-0053 fold). No other divergence found.
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 the clamped station price)¶
The modifiers stack on the station base unit price (already carrying supply/demand + 15% spread + any Class-8/9/11 premium); the commodity hard band is the final clamp. Direction inverts for sells โ a favoured player pays less to buy but is paid more to sell:
buy (player pays):
unit_price = base_sell_price
ร (1 - rank.trading_bonus/100) # Recruit 0% โ Fleet Admiral 50%
ร faction_reputation_multiplier # 0.85 Exalted โ 1.50 Public Enemy
ร personal_reputation_multiplier # 0.90 Legendary โ 1.20 Villain
ร (1 + region.tariff_rate) # commerce policy, capped per E-F2
ร (1 + station.price_adjustment_lever) # ยฑ10%, skipped same-owner per E-F1
then clamp to COMMODITY_PRICE_RANGES[commodity] <- FINAL
sell (player is paid):
unit_price = base_buy_price
ร (1 + rank.trading_bonus/100)
รท faction_reputation_multiplier
รท personal_reputation_multiplier
รท (1 + region.tariff_rate)
รท (1 + station.price_adjustment_lever)
then clamp to COMMODITY_PRICE_RANGES[commodity] <- FINAL
Region tax is NOT in the unit-price stack. It is a flat regional-treasury levy on the transaction TOTAL, after the unit price is clamped (treasury revenue per ADR-0059):
buy: total_with_tax = unit_price*units + int(unit_price*units ร region.tax_rate) # add-on
sell: net_earnings = unit_price*units - int(unit_price*units ร region.tax_rate) # withholding
Tax is never multiplied into the per-unit price and never subject to the band clamp. region.tax_rate (flat levy on all in-region commerce) and region.tariff_rate (per-unit modifier inside the stack) are separate concepts.
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¶
โ
Shipped. Per ADR-0053 WR14. market_bootstrap.py:build_market_prices() is the single source of truth for creation-time spread pricing, called eagerly at every station-creation site: bang_import_service.py:1849 (worldgen Phase 10/Phase 11 anchor injection, same transaction as station creation), construction_service.py:_seed_station_market_book() (runtime-constructed TradeDocks), and port_ownership_service.py:_seed_station_market_book() (stations transitioning into a fresh tradeable/ownable state). Both _seed_station_market_book() implementations are idempotent via an existing-commodity skip. Tested: tests/unit/test_market_bootstrap.py (spread contract), tests/unit/test_region_funded_tradedock.py (runtime-construction path). (re-verified 2026-08-04)
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_pricescall, 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
PriceAlertrow with itscurrent_value,severity, andmessagefor the admin economy view;auto_resolvealerts clear themselves once the value returns withinresolve_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¶
sell_price > buy_pricefor every commodity at every station (the 15% spread plus the floor rule).- Station stock is never negative:
0 โค quantity โค capacity. - Commodity prices clamp inside
COMMODITY_PRICE_RANGES[*]even at extremes. - A transaction either fully settles or fully rolls back โ credits and cargo move together.
Player.credits โฅ 0at all times.Station.commoditiesJSONB is mutated throughflag_modifiedso SQLAlchemy persists the diff.MarketPrice.previous_*always reflects the immediately prior tick (no skipping).- 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 |
Related¶
- DATA_MODELS:
../DATA_MODELS/economy.md. - FEATURES:
../FEATURES/economy/trading.md. - SYSTEMS: realtime-bus.md, aria-dialogue.md (market intelligence).
- REST API: trading routes auto-published at
<api-host>/docs. - OPERATIONS:
../OPERATIONS/aria.mdโ ARIA market intelligence consumer.