yuqi-zheng

SSE vs SZSE Call Auction: Where the Two Exchanges Tie-Break Differently


Both the Shanghai (SSE) and Shenzhen (SZSE) exchanges open and close every trading day with a call auction (集合竞价) rather than continuous matching. The mechanics are nearly identical: a fixed window where orders accumulate, then a single clearing price that maximizes execution volume. But the two exchanges diverge in one subtle, consequential rule — the tie-break used when more than one price satisfies the maximum-volume condition. Get it wrong in a cross-market engine and a Shanghai name can quietly print at a Shenzhen price.

This article goes through the official rule sets, a concrete worked example, and the implementation gap I found while reading a production A-share matching engine’s call_auction() routine.


When the auction runs

PhaseTimeNotes
Opening auction9:15 – 9:25No cancels accepted 9:20 – 9:25
Continuous trading9:30 – 11:30, 13:00 – 14:57Standard order-by-order matching
Closing auction14:57 – 15:00No cancels; SSE added this in 2018

The cancel restrictions matter: between 9:20 and 9:25, and throughout the closing auction, the host rejects cancellations. The book you see at the close of the window is the book that prices — there is no last-millisecond pull.


The shared core

Both exchanges determine the opening or closing price from the same three foundational conditions:

  1. Maximum executable volume — among all eligible prices, pick those at which the most shares trade.
  2. All higher buys, all lower sells clear — every buy order priced above the clearing price and every sell order priced below it must be fully filled.
  3. At least one side fully fills at the price — if the clearing price equals a buy or a sell order, that side must be exhausted.

So far, identical. Every price surviving condition 3 is a candidate price. The divergence is purely in how to choose among those candidates.


Where they diverge: the final tie-break

This is the entire difference. Everything above is shared; only the last step differs.

Shanghai (SSE), Trading Rules §3.5.2 (2026 revision):

两个以上申报价格符合上述条件的,使未成交量最小的申报价格为成交价格;仍有两个以上使未成交量最小的申报价格符合上述条件的,其中间价为成交价格。

Translated: if multiple prices qualify, first take the one with the smallest unmatched (residual) volume. If that still leaves multiple prices, take the median (中间价) of those remaining prices.

So SSE has a two-stage tie-break:

max volume → min residual → median of remaining candidates

Shenzhen (SZSE), Trading Rules:

两个以上申报价格符合上述条件的,取距前收盘价最近的价格为成交价格。

Translated: if multiple prices qualify, take the one closest to the previous close.

SZSE collapses to a single tie-break:

max volume → closest to previous close

Neither exchange borrows the other’s final step. SSE’s median and SZSE’s nearest-to-pre-close are mutually exclusive resolvers.

StepSSESZSE
Maximum volumeMaximum volume
Higher buys / lower sells all clearHigher buys / lower sells all clear
At least one side fully fillsAt least one side fully fills
Smallest unmatched volume
Median of remaining pricesClosest to previous close

SZSE’s resolver echoes TSE Itayose

Readers of our companion article on TSE Itayose Auction Pricing will recognize that SZSE’s final step is not unique to Shenzhen — it is the same instinct that drives TSE’s Condition 5 (reference-price fallback).

In Itayose, after the first four conditions (valid range → max volume → minimum surplus → imbalance direction) narrow the candidate set to a band [lowest sell-heavy, highest buy-heavy], Condition 5 applies the reference price (base_price, initially the previous day’s close):

CaseTSE choice
base_price within the narrowed bandbase_price
base_price above the bandnarrowed_high
base_price below the bandnarrowed_low

That is exactly “clamp the previous close into the viable price range.” SZSE’s rule — pick the candidate price closest to the previous close — is the same operation with the viable range already reduced to the set of maximum-volume prices. For a contiguous candidate band the two are equivalent: the clamped prior close is the nearest candidate.

The shared philosophy is the important part: when order flow is balanced and does not itself decide the price, both venues anchor the print to the prior close rather than letting it drift to an extreme endpoint. The prior close is a stability anchor against a flat residual.

Two caveats keep them from being identical:

  • Dynamic vs static anchor. TSE’s base_price can be updated in real time — when a circuit breaker trips, the exchange broadcasts a new reference price via the O tag and the auction re-anchors. SZSE’s previous close is fixed for the whole session.
  • How the band is computed. TSE narrows with four richer intermediate conditions (including a min-surplus and an imbalance-direction check) and uses price-dependent tick sizes. SZSE’s candidate set is simply “all prices achieving maximum volume.”

A neat corollary: among the three exchanges we’ve covered, SZSE is the one that behaves like TSE, not SSE. SSE’s median (step ⑤) is prev-close-independent — it splits the candidate range in the middle regardless of where the prior close sits. So when porting auction logic across venues, SZSE and TSE share the “fall back to the prior close” reflex; SSE does not.


A worked example

Suppose the previous close is ¥10.13, the tick is ¥0.01, and the book is a balanced “gap” book — every bid sits at or above ¥10.20, every ask sits at or below ¥10.10, with nothing in between:

SidePriceQty
Bid10.20500
Bid10.30500
Ask10.00500
Ask10.10500

For any price P in the range [10.10, 10.20], the executable volume is min(total bid, total ask) = 1000, and the residual (unmatched) volume is 0 — the book is perfectly balanced across the whole range. So every price from ¥10.10 to ¥10.20 is a candidate, all achieving the maximum volume with zero residual.

Now apply the two rules:

  • SSE: residual is already minimal everywhere, so it jumps to step ⑤ — the median of the candidate range. The midpoint of [10.10, 10.20] is ¥10.15.
  • SZSE: the price closest to the previous close ¥10.13 that lies inside the range is ¥10.13 itself (distance 0). So the opening price is ¥10.13.

A two-tick (¥0.02) difference from a single rule clause. At a ¥100 stock in a liquid name, that gap propagates into every opening-print-dependent signal — VWAP benchmarks, mark-to-market, pre-trade analytics.


Inside a matching engine

A production A-share matching engine I reviewed implements call_auction() with a greedy max-volume loop followed by a tie-break chain. Condensed and with names simplified, it looks like this:

// Representative reconstruction of a production A-share engine's
// collection-auction price resolver (call_auction).
double call_auction(OrderQueue& bidq, OrderQueue& askq,
                    double pre_close, double tick) {
    // --- Phase 1: greedy max-volume matching -----------------------
    long long dealed = 0;
    double prev_best_bid = 0, prev_best_ask = 0;
    while (askq.best_price() <= bidq.best_price()) {
        long long v = std::min(askq.best_vol(), bidq.best_vol());
        dealed += v;
        prev_best_ask = askq.best_price();
        prev_best_bid = bidq.best_price();
        askq.simu_deal_best(v);
        bidq.simu_deal_best(v);
    }

    // --- Phase 2: opening-price tie-break --------------------------
    double open;
    if (dealed == 0) {
        open = pre_close;                              // empty book fallback
        if (askq.best_price() < pre_close) open = askq.best_price();
        if (bidq.best_price() > pre_close) open = bidq.best_price();
        return open;
    }

    double best_ask = askq.best_price();
    double best_bid = bidq.best_price();

    // (a) one side still has size at the last crossed level
    if (askq.non_empty() && best_ask == prev_best_ask) return prev_best_ask;
    if (bidq.non_empty() && best_bid == prev_best_bid) return prev_best_bid;

    // (b) min-residual proxy: pick the side with less leftover
    long long ask_left = askq.best_vol();
    long long bid_left = bidq.best_vol();
    double ask_bound = prev_best_bid;                  // lower end of range
    double bid_bound = prev_best_ask;                  // upper end of range
    if (askq.non_empty()) ask_bound = best_ask - tick;
    if (bidq.non_empty()) bid_bound = best_bid + tick;

    if (ask_left != bid_left) {
        return (ask_left < bid_left) ? best_ask : best_bid;
    }

    // (c) FINAL resolver: nearest to previous close within the range.
    //     This IS the SZSE rule. SSE's 中间价 (median) is absent.
    open = pre_close;
    if (pre_close < bid_bound)      open = bid_bound;
    else if (pre_close > ask_bound) open = ask_bound;
    return open;
}

Two things stand out:

  1. There is no exchange branch. The function never inspects whether the instrument is SSE or SZSE. The same code prices a Shanghai stock and a Shenzhen stock.
  2. The final resolver is hardcoded to “nearest to previous close.” That is precisely the SZSE rule. The SSE median step (⑤) is simply not there.

The missing SSE step, quantified

The engine is correct for SZSE by construction. For SSE it is correct only in the degenerate case where “min residual” (step ④) already collapses the candidate set to a single price. In our worked example — where residual is flat (zero) across the whole [10.10, 10.20] range — step ④ does not narrow anything, so the engine falls through to the SZSE resolver and prints ¥10.13 instead of the SSE-mandated median ¥10.15.

More precisely, the defect triggers whenever the set of prices achieving maximum volume with minimum residual spans more than one tick. In that region:

  • SSE requires the median of the candidate range.
  • The engine has no code path to compute a median — it only ever compares the two extreme residual queues and then resolves by previous close.

So a Shanghai name in a balanced, gapped book gets priced with Shenzhen logic. The gap is narrow in practice (it only fires on wide, balanced openings and resumed halts), but it is a genuine compliance defect: the printed opening price for an SSE security would not match the exchange’s.


A correct reference implementation

The fix is small and local. Instead of resolving on the fly, collect the full candidate set during the volume scan, then branch on the exchange for the final step:

// Collect EVERY price that achieves max volume with minimal residual.
std::vector<double> candidates = scan_candidate_prices(bidq, askq, tick);

double open;
if (exchange == Exchange::SSE) {
    // Step ⑤: median of the candidate range (中间价).
    std::sort(candidates.begin(), candidates.end());
    open = candidates[candidates.size() / 2];        // round to tick per §3.5.4
} else { // SZSE
    // Step ④: nearest to previous close.
    open = *std::min_element(candidates.begin(), candidates.end(),
        [=](double a, double b) {
            return std::abs(a - pre_close) < std::abs(b - pre_close);
        });
}

For a contiguous candidate range the median is just the midpoint; for an even count the exchange rounds to the nearest tick (SSE §3.5.4). The key change is structural: the engine must materialize the candidate set rather than infer the price from two leftover queues.


Why this matters

Cross-market engines — especially those rebuilt from a single A-share reference implementation — routinely inherit one exchange’s tie-break for both. The cost is silent:

  • No crash, no exception, no log line.
  • The opening print simply disagrees with the tape by a tick or two on the days it matters most: wide openings, volatility-halted names resuming, and low-liquidity securities.
  • For a firm doing post-trade reconciliation or publishing indicative prices, a systematic SSE/SZSE mismatch is a regulatory and P&L problem, not a cosmetic one.

The only reliable guard is a regression vector that forces the multi-price, min-residual case and asserts the SSE median against the SZSE nearest-close. Without it, the bug hides in plain sight because the common single-price case always passes.


Summary

  • Both exchanges share the max-volume + all-clear + one-side-full core (steps ①–③).
  • SSE adds a two-stage tie-break: smallest unmatched volume (④), then median of remaining prices (⑤).
  • SZSE uses a single tie-break: closest to previous close (④).
  • A common engine hardcodes the SZSE resolver and omits the SSE median — correct for Shenzhen, silently wrong for Shanghai whenever the candidate set spans multiple ticks.
  • Fix locally: collect the candidate price set during the volume scan, then branch on the exchange at the final step.

References

  • Shanghai Stock Exchange Trading Rules (2026 revision), §3.5.2
  • Shenzhen Stock Exchange Trading Rules — call-auction price determination
  • See also: TSE Itayose Auction Pricing, for a five-condition cascade on a different venue