ServicesSurge Pricing

Surge Pricing

Rule-based dynamic pricing: define a policy as an ordered list of price transforms (multipliers, discounts, lookups, charm rounding) with conditions, then compute final prices for items against a real-time context - with policy versioning, what-if simulation, evaluation traces, and a TTL'd demand-metric store. Every example on this page was executed against a live server.

When to reach for it: ride-share or delivery surge multipliers, time-of-day pricing, zone- or tier-based price tables, demand-driven API pricing, what-if simulation of a pricing change before shipping it.

When not to: static offers and scheduled price drops belong to Chrono or a Cart with fixed prices; usage metering and billing counters belong to Meter.

Concepts

  • A policy is an ordered rule pipeline. Each enabled rule whose conditions pass transforms the running price; the output of one rule feeds the next. The final price is rounded to 2 decimals.
  • Rules have a rule_type, params, optional conditions, and an enabled flag - the six types are cataloged in Rules and conditions.
  • Context is the JSON sent with each calculation; conditions and dynamic multipliers read from it via dot paths (user.tier).
  • Every update is a new version. The latest becomes active; old versions stay readable and rollback re-activates one in place.
  • Metrics are a global, TTL-expiring store for demand signals - they do not feed calculations automatically.
  • Roles: creating, updating, deleting, or rolling back policies and creating lookup tables require a platform admin or editor role; calculating, simulating, and all reads work with any token.

Define a policy

policy create takes a JSON file with policy_id, description, and rules:

{
  "policy_id": "rideshare",
  "description": "Rideshare surge demo",
  "rules": [
    { "name": "demand_surge", "rule_type": "multiply", "params": { "multiplier_field": "demand" },
      "conditions": [ { "field": "demand", "operator": "greater_than", "value": 1.0 } ] },
    { "name": "late_night", "rule_type": "multiply", "params": { "multiplier": 1.2 },
      "conditions": [ { "field": "clock", "operator": "time_in_range", "value": ["22:00", "05:00"] } ] },
    { "name": "charm", "rule_type": "round_psychological", "params": {}, "conditions": [] }
  ]
}
snug surge-pricing policy create --file ./policy.json   # admin or editor role
snug surge-pricing policy get --policy-id rideshare
snug surge-pricing policy list

The CLI's policy list takes no filters; the HTTP list endpoint speaks the shared search grammar (q over descriptions, filter=deleted:eq:true, sort_by, pagination).

Calculate prices

calculate is batch and item-centric: each item carries its own base_price, and one context applies to all. Batch with --items-file (a JSON array of {item_id, base_price}) and override the currency label with --currency EUR - both verified.

snug surge-pricing calculate --policy-id rideshare --item-id ride1 \
  --base-price 10.0 --context '{"demand":1.8,"clock":"23:30"}'

Verified: base 10.00 with demand: 1.8 at 23:30 came back 21.99 (surge x1.8, late night x1.2, charm-rounded); with demand: 0.8 at noon, 9.99. Over HTTP:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"policy_id":"rideshare","items":[{"item_id":"ride1","base_price":10.0}],
       "context":{"demand":1.5,"clock":"12:00"}}' \
  http://localhost:4000/api/v1/surge-pricing/calculate
{
  "status": 200,
  "msg": "OK",
  "data": {
    "results": [
      { "item_id": "ride1", "final_price": 14.99, "currency": "USD", "trace_id": "uYqFxHDRXThK" }
    ]
  }
}

Explain, trace, simulate

--explain inlines a per-rule breakdown and persists it as a retrievable trace; --persist-trace stores the trace without inlining. Captured live:

{
  "item_id": "ride1",
  "final_price": 21.99,
  "explanation": [
    { "rule": "demand_surge", "effect": "Increased by 80.0%", "result": 18.0 },
    { "rule": "late_night", "effect": "Increased by 20.0%", "result": 21.6 },
    { "rule": "charm", "effect": "Charm-rounded to $21.99", "result": 21.99 }
  ],
  "trace_id": "uUSCGPzySDuH"
}

Fetch it back later with snug surge-pricing trace get --trace-id .... Every result carries a trace_id, but the trace is only retrievable if you passed --explain or --persist-trace - otherwise 404 trace_not_found (reproduced live). Traces expire after 24 hours.

For a what-if without touching stored state, policy simulate takes one --base-price plus --context '{...}' or --contexts-file and returns the price and explanation per context. Verified: base 10.00 across three contexts returned 9.99, 14.99, and 26.99 as demand rose.

Metrics: feeding demand signals

snug surge-pricing metric push --name demand --value 1.8   # or --json '{...}'
snug surge-pricing metric get --name demand
snug surge-pricing metric list

Metrics are global (keyed by name, not per policy) and expire after 300 seconds by default, so push on an interval. Pushing a metric does not change calculations - rules only read the per-request context (verified: with demand pushed, calculating without --context still failed field_not_found). The wiring is yours:

demand=$(snug --output json surge-pricing metric get --name demand | jq '.value')
snug surge-pricing calculate --policy-id rideshare --item-id ride1 \
  --base-price 10.0 --context "{\"demand\":$demand,\"clock\":\"12:00\"}"

Versions, rollback, delete

snug surge-pricing policy update --policy-id rideshare --file ./policy-v2.json
snug surge-pricing policy history --policy-id rideshare
snug surge-pricing policy get --policy-id rideshare --version 1
snug surge-pricing policy rollback --policy-id rideshare --version 1
snug surge-pricing policy delete --policy-id rideshare

All verified live: update replaces the whole rule list (not a merge). rollback re-activates the target version in place - after rolling back from version 2 to 1, history showed active_version: 1 with both versions intact; no new version number is minted. delete is a soft delete: subsequent reads fail with 410 policy_deleted (not 404), and the policy disappears from list unless include_deleted=true.

Behaviors and gotchas

Each reproduced live:

  • There are no price caps or floors. The final price is purely what the rules compute: an add of -50 on a 10.00 base returned -40.0, and a context demand of 500 returned 4999.99. Clamp via rule conditions or in the caller if you need bounds.
  • A multiplier_field value from context is not range-checked - the 0.01-100 bound applies only to a literal multiplier in the policy.
  • A field a rule needs but the context lacks is an error, not a skip: 400 field_not_found names the field.
  • Lookup references resolve at calculate time, not create time. A policy naming a nonexistent table creates fine but fails calculation with 404 lookup_table_not_found.
  • Charm rounding can raise the price: 21.6 became 21.99. Whole numbers drop first (20.00 becomes 19.99).

Limits and configuration

Policies hold up to 50 rules, calculations up to 1000 items, and simulations up to 500 contexts per request by default; metric TTL (300s), trace TTL (24h), and the default currency (USD) are tunable via the SURGE_PRICING_* variables in the Surge Pricing CONFIG reference.

Reference

On this page