Auction House: Proxy Bidding, Soft Close and Settlement
MediumAI-assisted coding · AI-Assisted Debugging
Auction House: Proxy Bidding, Soft Close and Settlement
You've joined the marketplace team at Gavel & Co., which runs online auctions for collectibles and charity galas. The auction engine is a small in-memory service. This week support escalated an incident (AUC-412: "the price is wrong"), and the roadmap has three pieces of work queued behind it. All money is integer cents, all times are integer seconds.
Codebase
| File | What it does |
|---|---|
gavel/api.py | run(operations) entry point and the Dispatcher that routes each operation |
gavel/bidding.py | AuctionService: list lots, take bids (retractions and buy-it-now come in Part 2) |
gavel/pricing.py | Proxy-bidding state of a lot: leader, runner-up, price, reserve |
gavel/increments.py | increment_for(price): bid increment lookup |
gavel/rules.py | Validation: identifiers, listing prices, the minimum acceptable bid |
gavel/closing.py | Closer: lazy closing of lots whose end time has been reached |
gavel/catalog.py | Catalog: every lot ever listed, in listing order |
gavel/views.py | The wire representation of a lot |
gavel/models.py | Lot and Bid records, lot status constants |
gavel/money.py | Integer-cent helpers, including div_half_up |
gavel/config.py | Policy constants: increment table, retraction/soft-close limits, premium tiers, shipping |
gavel/errors.py | AuctionError and every error code |
gavel/settlement.py, gavel/index.py | Placeholders for Parts 3 and 4 |
tests/ | Existing tests (two of them currently fail) |
catalog.py, money.py and errors.py are known-good. config.py is the single source of truth for every table and constant quoted below: if this README and the code disagree about a *number*, the README is the specification and config.py is where it lives.
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh auction house and returns one result per operation. Every operation is [name, ts, ...args]; ts is an integer and never decreases from one operation to the next. Errors are returned as "error: <code>" strings, never raised.
- An unknown name returns
"error: unknown operation". A wrong number of arguments, atsthat is not an integer (true/falseare not integers), or an operation that is not a list returns"error: invalid arguments". So does a bidder, or the lot id given tolist, that is not a non-empty string; these shape errors are reported before any error in the "Errors" columns below. Looking up any other lot id that does not exist islot not found. - An amount is a genuine integer (
trueand12.5are not amounts). - Lot ids and bidder names are case-sensitive.
Operations (Part 1)
| Operation | Arguments after ts | Returns | Errors, checked in this order |
|---|---|---|---|
list | lot_id, start_price, reserve, buy_now, ends_at | true | lot exists, invalid price, invalid end |
bid | lot_id, bidder, max | the lot view | invalid amount, lot not found, lot closed, not a raise, bid too low |
lot | lot_id | the lot view | lot not found |
reserveandbuy_noware an integer ornull(none).listis valid whenstart_price > 0,reserveisnullor>= start_price,buy_nowisnullor>= start_priceand>= reserve(any violation isinvalid price), andends_at > ts(elseinvalid end).- Lot view:
[status, leader, price, reserve_met, ends_at].statusis"open","sold"or"passed". While open,leaderis the bidder currently winning (ornull) andpriceis the *visible price* defined below (ornull). A sold lot shows its buyer and its sale price withreserve_mettrue. A passed lot shows["passed", null, null, false, ends_at].
The proxy-bidding rules
A bidder submits a maximum. A bidder's *standing bid* on a lot is their most recent active bid; a bid is accepted only if it is a raise, so a bidder's maximum only ever goes up. Every accepted bid gets the next value of a sequence number seq (1, 2, 3, ... across the whole house, never reused). A lot's state is a pure function of its standing bids:
- Leader: the highest maximum. Equal maximums go to the lower
seq(the earlier bid). A raise is a new bid and gets a new, higherseq. - Runner-up: the standing bid ranked second by the same ordering (so against a tie, the loser of the tie).
- Base price: the start price when only one bidder has a standing bid. Otherwise
min(leader_max, runner_up_max + increment_for(runner_up_max)), where the increment depends on the band of the price being beaten, i.e. the runner-up's maximum:
| Price being beaten | Increment |
|---|---|
| below $25.00 | $0.50 |
| 100.00 | $1.00 |
| 500.00 | $5.00 |
| 2,500.00 | $25.00 |
| $2,500.00 and above | $100.00 |
Band lower bounds are inclusive: a price of exactly 1.00 band.
- Reserve met:
leader_max >= reserve. A lot with no reserve is met as soon as it has a bid. - Working price: the base price; if the reserve is met and the lot has a reserve, at least the reserve (
max(base, reserve)). Because of this "reserve lift", a leader who raises their own maximum from under the reserve to at or over it moves the price to the reserve. - Visible price: the working price if the reserve is met, otherwise
null. Bidders never see a price below the reserve. A lot with no bids has leadernull, pricenull,reserve_metfalse.
A leader raising their own maximum changes the price only if the base price was capped by their old maximum (the runner-up was close) or the reserve lift now applies; otherwise the price is unchanged.
Accepting a bid. After the error checks in the table above: not a raise if the bidder already has a standing bid with max <= that bid's maximum. bid too low if max < start_price, or if the bidder is not the current leader, the lot has a leader, and max < working_price + increment_for(working_price). (The leader's own raise has no such floor.) A tie with the leader is therefore possible only when the challenger's maximum clears that floor.
Closing is lazy. There is no timer. Before it handles every operation, the dispatcher closes every open lot whose ends_at <= ts: sold to the leader at the working price if the reserve is met, otherwise passed. An operation at exactly ends_at therefore already sees a closed lot (bid -> lot closed).
Worked example
[["list", 0, "vase", 1000, null, null, 100000],
["bid", 1, "vase", "ana", 9000],
["bid", 2, "vase", "ben", 4000],
["bid", 3, "vase", "ana", 20000]]returns
[true, ["open","ana",1000,true,100000], ["open","ana",4100,true,100000], ["open","ana",4100,true,100000]]Ben's 1.00 band, so the price is 200.00 does not move it. And with a $50.00 reserve:
[["list", 0, "coin", 1000, 5000, null, 100000], ["bid", 1, "coin", "ana", 3000], ["bid", 2, "coin", "ana", 8000]]returns [true, ["open","ana",null,false,100000], ["open","ana",5000,true,100000]].
Part 1 - Incident AUC-412: the price is wrong
Goal: make list, bid and lot follow every rule above. Support has collected these reports:
- "A 300.00 maximum and the price showed 99.00 is 100.00." / "A bid of exactly $25.00 gets the wrong increment."
- "I raised my own maximum and the displayed price shot up to my maximum." (Sellers were furious: they think the price is higher than anyone has paid.)
- "Two of us bid the same maximum and the later bidder won."
- "The price of a charity lot with a 12 before the reserve was met," and "my $500 maximum met the reserve but the lot never showed a price."
- "I bid exactly the amount the site told me was enough and it said *bid too low*" (after the reserve lift, the minimum next bid must be measured from the lifted price).
tests/test_pricing.py shows how to drive the engine; two of its tests currently fail. Existing callers rely on the wire format above staying exactly as it is.
Part 2 - Retractions and buy-it-now (AUC-431)
Two new operations:
| Operation | Arguments after ts | Returns | Errors, checked in this order |
|---|---|---|---|
retract | lot_id, bidder | the lot view after the retraction | lot not found, lot closed, no active bid, retraction window closed, retraction limit |
buy_now | lot_id, bidder | the lot view after the sale | lot not found, lot closed, buy now unavailable |
Retraction rules
retractwithdraws the bidder's most recent active bid on the lot. If the bidder had an earlier active bid on that lot, it becomes their standing bid again with its originalseq. The lot's leader, price and reserve status are then recomputed from the standing bids that remain; the price can go down, and the leader can change.retraction window closedwhenends_at - ts < 3600(exactly 3,600 s left is still allowed).- A bidder may retract at most 2 bids per lot. Once they have used both, further attempts return
retraction limit(a refused attempt never counts). - After a retraction the bidder can bid again on the lot; it is an ordinary bid.
- Retracting does not change
ends_atand cannot reopen a lot.
Buy-it-now rules
buy_nowsells the lot immediately tobidderat the lot'sbuy_nowprice; it returns["sold", bidder, buy_now, true, ends_at]. Any standing bids are ignored.- It returns
buy now unavailableif the lot has nobuy_nowprice, or if the reserve is currently met (for a lot with no reserve, that means it currently has any standing bid). A leader who is still below the reserve does not block it. - After a sale the lot is
sold: laterbid,retractandbuy_nowcalls returnlot closed.
Example (lot vase, no reserve, ends at 100000)
[["bid",1,"vase","ana",9000], ["bid",2,"vase","ben",4000], ["bid",3,"vase","cat",6000],
["retract",4,"vase","cat"], ["retract",5,"vase","ben"], ["retract",6,"vase","ben"]]returns [["open","ana",1000,true,100000], ["open","ana",4100,true,100000], ["open","ana",6100,true,100000], ["open","ana",4100,true,100000], ["open","ana",1000,true,100000], "error: no active bid"].
Part 3 - Soft close and settlement (AUC-455)
Soft close (anti-sniping). An accepted bid placed at time ts on an open lot with ends_at - ts < 120 (strictly fewer than 120 seconds left) moves the end to ts + 120, provided the lot has been extended fewer than 5 times so far; that counts as one extension. A bid with exactly 120 seconds left does not extend. Rejected bids never extend; retractions and buy-it-now never extend. The lot view returns the new ends_at, and the lazy closing rule uses the current end time, so an extended lot stays open past its original end.
Requirement change: buy-it-now becomes permanent-off. Sellers were using retractions to reopen their own buy-it-now. From now on, once the reserve has been met by an accepted bid (for a lot without a reserve: once it has received its first accepted bid), buy_now on that lot returns buy now unavailable for the rest of the auction, even if retractions later bring the lot back under its reserve. This replaces rule 2 of the buy-it-now rules in Part 2 for that case; everything else in Part 2 is unchanged.
Settlement. ["settle", ts, bidder] produces one invoice for all the lots bidder has won that have not been invoiced yet (sold lots, including buy-it-now sales; passed lots are never invoiced) and marks them invoiced. Errors: invalid arguments (bidder not a non-empty string), nothing to settle (nothing uninvoiced, which does not use up an invoice number). Because of lazy closing, settle first closes every lot that is due at its ts.
The invoice is {"invoice": "INV-0001", "buyer": ..., "lines": [{"lot", "hammer", "premium"}, ...], "shipping": ..., "total": ...}:
- Invoice numbers are
INV-plus a 4-digit zero-padded counter shared by all buyers, in the order thesettlecalls succeed. - Lines are in the order the lots closed: by
ends_atat the moment of closing, ties by listing order; a buy-it-now sale closes at the moment of the purchase, after any lots that were already due. hammeris the lot's sale price. Premium is computed per line (never on the invoice total) and is marginal: 22.50% (2,250 basis points) of the part of the hammer price up to $1,000.00 (100,000) plus 15.50% (1,550 bp) of the part above it, rounded once per line, to the nearest cent with exact halves rounding up:div_half_up(part1 * 2250 + part2 * 1550, 10000). Examples: hammer 1060 -> 238.5 -> 239; hammer 20 -> 4.5 -> 5; hammer 123456 -> 26,135.68 -> 26136. Python'sround()is banker's rounding and is wrong here.shippingis a flat 1,250 per invoice, 0 when the sum of the invoice's hammer prices is at least 100,000 (inclusive).totalis the sum of hammers, premiums and shipping.
Example
[["list",0,"vase",1000,null,null,1000],
["bid",800,"vase","ana",2000], ["bid",900,"vase","ben",2500], ["bid",1019,"vase","ana",3000],
["lot",1138,"vase"], ["lot",1139,"vase"]]returns [true, ["open","ana",1000,true,1000], ["open","ben",2050,true,1020], ["open","ana",2600,true,1139], ["open","ana",2600,true,1139], ["sold","ana",2600,true,1139]]. Ana's bid at 800 had 200 s left (no extension); Ben's at 900 had 100 s left (end -> 1020); Ana's at 1019 had 1 s left (end -> 1139).
Part 4 - Bidder dashboards and bulk tools at scale (stretch)
The mobile team is shipping a "My lots" screen, and the catalog team imports whole collections at once.
| Operation | Arguments after ts | Returns | Errors |
|---|---|---|---|
my_lots | bidder, limit | {"leading": {"count", "exposure", "lots"}, "won": {"count", "spent", "lots"}} | invalid arguments |
bulk_list | prefix, count, start_price, reserve, duration, stagger | count | invalid arguments, invalid price, invalid end, lot exists (in that order) |
bid_run | bidder, prefix, count, first_max, step, gap | [accepted, rejected] | invalid arguments |
bid_war | lot_id, prefix, count, first_max, step | [accepted, rejected] | invalid arguments |
my_lots:leadingcovers the bidder's open lots where they are the leader and the reserve is met:countof them,exposure= the sum of their current working prices, andlots= the ids of the firstlimitof them in plain ascending string order.woncovers sold lots whose buyer is the bidder (buy-it-now sales included, invoiced or not):count,spent= sum of sale prices, andlotsas above.limitmust be an integer>= 1; the bidder must be a non-empty string. An unknown bidder returns zeros and empty lists.bulk_listlistscount >= 1lots with idsf"{prefix}-{i:04d}"fori = 0 .. count-1(soz-0000,z-0001, ...; later digits just get longer past 9999). All lots sharestart_priceandreserve(an integer ornull) and have no buy-it-now price; lotiends atts + duration + (i % stagger)(justts + durationwhenstagger = 0).duration >= 1,stagger >= 0. It is atomic: if any id already exists nothing is listed.bid_runplaces one bid per lotf"{prefix}-{i:04d}",i = 0 .. count-1, forbidder, with maximumfirst_max + i * step; bidihappens at timets + i * gap(gap >= 0). The clock advances with the run: before each bid, lots due at that bid's time are closed exactly as for a normal operation, and the bid obeys every normal rule (soft close included). A bid that would return an error is counted inrejectedand does nothing else. The next operation'stsis never earlier than the last bid's time.bid_warhas biddersf"{prefix}{i}"(i = 0 .. count-1) bidfirst_max + i * stepon one lot, in that order, all atts; the lot may be missing (then every bid is rejected). Same counting.- Bulk argument checks: ids/prefixes are non-empty strings,
count >= 1, all other numbers are genuine integers.
Example
[["bulk_list",0,"z",3,1000,null,5000,0], ["bid_run",1,"ana","z",3,2000,500,0],
["bid_war",2,"z-0001","q",2,3000,1000], ["my_lots",3,"ana",5], ["lot",3,"z-0001"]]returns [3, [3,0], [2,0], {"leading":{"count":2,"exposure":2000,"lots":["z-0000","z-0002"]}, "won":{"count":0,"spent":0,"lots":[]}}, ["open","q1",3100,true,5000]].
Scale. Hidden tests list tens of thousands of lots, place tens of thousands of bids (one hot lot has thousands of bidders, retractions included) and call my_lots repeatedly while time moves through the end times. The whole suite has a few seconds. A solution that rescans the catalog per bid or per query, or re-sorts every bid of a hot lot on each new bid, will time out.
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later Parts must not break earlier ones (except where a Part says it replaces a rule); hidden tests re-check earlier behaviour after the later features exist.
- Everything observable is specified above or visible in the code; when this document says "as for a normal operation", read
api.pyandclosing.pyto see what that means. - Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["list",0,"vase",1000,null,null,100000],["bid",1,"vase","ana",9000],["bid",2,"vase","ben",4000],["bid",3,"vase","ana",20000]]
- Output:
- [true,["open","ana",1000,true,100000],["open","ana",4100,true,100000],["open","ana",4100,true,100000]]
- Explanation
- Ben's 40.00 maximum is in the 1.00-increment band, so the price is 41.00. Ana raising her own maximum to 200.00 does not move the price. A lot view is [status, leader, price, reserve_met, ends_at].
Example 2
- Input:
- [["list",0,"coin",1000,5000,null,100000],["bid",1,"coin","ana",3000],["bid",2,"coin","ana",8000]]
- Output:
- [true,["open","ana",null,false,100000],["open","ana",5000,true,100000]]
- Explanation
- Under the 50.00 reserve the price is hidden. Once the leader's maximum reaches the reserve, the price is lifted to the reserve.
Example 3
- Input:
- [["list",0,"vase",1000,null,null,1000],["bid",800,"vase","ana",2000],["bid",900,"vase","ben",2500],["bid",1019,"vase","ana",3000],["lot",1138,"vase"],["lot",1139,"vase"]]
- Output:
- [true,["open","ana",1000,true,1000],["open","ben",2050,true,1020],["open","ana",2600,true,1139],["open","ana",2600,true,1139],["sold","ana",2600,true,1139]]
- Explanation
- Soft close: Ben's bid had 100 s left, so the end moves to 1020; Ana's bid at 1019 had 1 s left, so it moves to 1139, where the lot finally closes sold at 26.00.
Guidelines
- Python 3 standard library only
- All money is integer cents (no floating point); all times are integer seconds and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Auctions close lazily: there is no timer, only the timestamp of the next operation
- Part 4 inputs list tens of thousands of lots and place tens of thousands of bids with a few thousand operations; each run has a few seconds