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_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.