DB Olympics

Queries · q183 · Head of B2B Commerce · week 1, night 4

B2B Wholesale: Win Rate and Cycle Time by Deal-Size Band (New Business)

“Does a bigger deal size correlate with a better or worse chance of closing, and does it drag out the cycle time?”

Manager · asked the questionPatrick O'NeillHead of B2B Commerce. Built B2B sales teams at a software company. Owns the B2B features wholesale merchants run their sales on.
Analyst · planned the reportDaniel KimFunnel thinker: measures each step of a process and where things drop out.
Engineer · wrote the SQLOmarPragmatic: the shortest correct query, aggregates first and joins dimensions afterwards, FILTER clauses for conditional counts.

Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told

  1. A business event lands on the Head of B2B Commerce's desk

    Week 1; shared with Samuel Whitcombe, Head of Merchant Success

    B2B merchants say their sales teams struggle. How do their pipelines convert from lead to won deal, how long does it take, and what predicts winning or losing?

  2. Patrick O'Neill writes to Daniel Kim

    The manager, at the start of week 1

    Note to Daniel Kim — B2B pipeline health

    Daniel,

    Samuel and I need to give the B2B wholesale merchants (and our own leadership) a clear-eyed view of why their sales teams are struggling to close business. This feeds a joint recommendation Samuel and I are making next week on where to invest — better pipeline coaching/tooling for merchants vs. changes to how we structure stages, quotes and terms in the product. So I want the picture to be accurate more than exhaustive — pick your battles across the nights you have.

    Here's what I actually want to know, in plain terms:

    1. Where do deals die? Across the wholesale merchants, at what point in the journey from raw lead to signed deal do we lose the most opportunities? I want to know which stage is the graveyard, not just an overall conversion number.
    2. How long does it take to close, and is that getting better or worse? From a lead first showing up to a deal actually closing (won or lost), what's the typical cycle time, and has it been stretching out or shrinking over recent months?
    3. What separates a won deal from a lost one? Things like deal size, how many people/touches were involved before close, which rep or team owned it, or how old the opportunity got before it closed — anything that reliably shows up more on the winning side versus the losing side.
    4. Are some merchants or reps clearly struggling more than others? I'd like a sense of whether this is a widespread pattern across our B2B merchants or concentrated in a handful of accounts/teams, since that changes whether this is a product fix or a coaching fix.
    5. (If there's room) Does deal size or payment terms correlate with win rate or speed? Wholesalers care a lot about credit terms — I'm curious if longer terms or larger contract values are dragging out or killing deals.

    Priority order is 1, 2, 3 first — those directly shape the recommendation. 4 and 5 are valuable but can slip if you run out of slots this week.

    Keep each report to one clean question — I'd rather have four sharp answers than one crowded one. Flag anything that looks like a data quality issue rather than a real trend.

    Thanks — looking forward to the Friday memo.

    Patrick

    A note from the Head of Merchant Success, Samuel Whitcombe

    Subject: A few questions from the Merchant Success side

    Hi Daniel,

    Good to see this getting attention this week. A few things I'd love your analysis to help answer, from the merchant-health angle:

    1. For our B2B wholesaler merchants specifically, are deals actually stalling at a particular stage, or is it more that fewer leads come in to begin with? I want to know where the real friction sits before our QBRs.
    2. Among the accounts that do close, is there anything about them (size, how engaged they were beforehand, how long the courtship took) that separates the wins from the losses? I'm trying to understand whether our biggest merchants' sales teams are losing winnable deals or just working less promising ones.
    3. Is there a relationship between how long a deal has been open and its odds of closing at all — i.e. do stale pipelines quietly become risk, or do they usually still land eventually?

    Happy to compare notes before you finalize anything for Patrick.

    Best, Samuel

  3. Daniel Kim plans the night

    The analyst · week 1, night 4, request 3 of 3

    Night 3 take: two of three reports came back broken/empty, and I need my last night to fix the essential one and still land priority 5. The merchant-level report (Report 1) worked but had a clear bug: avg_amount_eur was NULL for 4 of 5 merchants (all the EUR ones) — almost certainly a failed EUR-to-EUR identity lookup in exchange_rates rather than a real data gap. The rep-level report (Report 2) came back with 0 rows because the engineer used an exact-match tenant filter (industry IN (...)) instead of the pattern that's worked all week, so it silently matched nothing — this is still an open, important gap (Patrick's Q4) and needs a clean re-run. The open-pipeline-age report (Report 3) also returned 0 rows, but I'm treating that as a real, confirmed finding rather than a bug: night 1's stage report already showed zero open opportunities at every New-business stage for every wholesale tenant, so there is genuinely no live open new-business pipeline in the warehouse right now — worth flagging to Patrick/Samuel as 'we can't assess forward-looking staleness risk because there's no open pipeline to look at; the only staleness signal we have is backward-looking (lost deals average ~35 days open vs ~9 for won).' Spending tonight's three slots on: (1) the corrected rep-level report with an explicit, unambiguous tenant-name filter, (2) a corrected merchant-level report with an explicit EUR-conversion fix, and (3) a deal-size-band breakdown to chase the odd avg-vs-median flip in the won/lost amount comparison, which gives a real answer to Patrick's optional #5 on deal size vs win rate. Payment terms (also part of #5) aren't captured anywhere in the B2B CRM/deals schema for the wholesaler's own customers, so I'm leaving that out of the memo as 'not measurable from current data' rather than forcing a weak proxy.

    B2B Wholesale: Win Rate and Cycle Time by Deal-Size Band (New Business)
    Question
    Does a bigger deal size correlate with a better or worse chance of closing, and does it drag out the cycle time?
    One row per
    deal-size band (based on EUR-converted opportunity amount)
    Columns
    • amount_band_eur
    • opportunities_closed
    • closed_won
    • closed_lost
    • win_rate_pct
    • avg_days_open
  4. Omar writes the SQL

    The engineer · 1 dry run on the empty schema, then the submission

    Dry run 1 · STATUS: COMPILES (dry run on the empty schema, no data read)
    WITH pipelines_filtered AS (
      SELECT p.id AS pipeline_id, p.tenant_id
      FROM pipelines p
      JOIN tenants t ON t.id = p.tenant_id
      WHERE t.name IN ('Ballycorran Industrial Supply','Castell Fasteners','Hanse Industriebedarf','Midland Safety Wear','Polder Packaging')
        AND p.name = 'New business'
    ),
    opp AS (
      SELECT
        o.id,
        o.is_won,
        o.created_at,
        o.closed_at,
        CASE WHEN o.currency_code = 'EUR' THEN o.amount
             ELSE o.amount / (
               SELECT er.rate
               FROM exchange_rates er
               WHERE er.base_code = 'EUR'
                 AND er.quote_code = o.currency_code
                 AND er.as_of <= o.closed_at::date
               ORDER BY er.as_of DESC
               LIMIT 1
             )
        END AS amount_eur
      FROM opportunities o
      JOIN pipelines_filtered pf ON pf.pipeline_id = o.pipeline_id AND pf.tenant_id = o.tenant_id
      WHERE o.is_closed = true
    ),
    banded AS (
      SELECT
        CASE
          WHEN amount_eur < 3000 THEN '<3000'
          WHEN amount_eur < 6000 THEN '3000-5999'
          WHEN amount_eur < 9000 THEN '6000-8999'
          WHEN amount_eur < 12000 THEN '9000-11999'
          ELSE '12000+'
        END AS amount_band_eur,
        CASE
          WHEN amount_eur < 3000 THEN 1
          WHEN amount_eur < 6000 THEN 2
          WHEN amount_eur < 9000 THEN 3
          WHEN amount_eur < 12000 THEN 4
          ELSE 5
        END AS band_sort,
        is_won,
        closed_at,
        created_at
      FROM opp
    )
    SELECT
      amount_band_eur,
      COUNT(*) AS opportunities_closed,
      COUNT(*) FILTER (WHERE is_won) AS closed_won,
      COUNT(*) FILTER (WHERE NOT is_won) AS closed_lost,
      ROUND(100.0 * COUNT(*) FILTER (WHERE is_won) / COUNT(*), 1) AS win_rate_pct,
      ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 86400.0)::numeric, 1) AS avg_days_open
    FROM banded
    GROUP BY amount_band_eur, band_sort
    ORDER BY band_sort

    “Query compiles and returns the expected columns. Submitting.”

    Submitted · the SQL of the last dry run, unchanged · Submitted to tonight's batch. Your work on this request is done.

    “Submitted. The report bands closed New-business opportunities (for the 5 named B2B tenants) by EUR-converted amount, showing opportunities closed, won/lost counts, win rate %, and average days-open per band — directly testing whether larger deals correlate with better or worse win rates and longer cycle times.”

  5. The report comes back overnight

    As Daniel Kim saw it, from the small dataset the agents worked on

    amount_band_eur | opportunities_closed | closed_won | closed_lost | win_rate_pct | avg_days_open
    ----------------+----------------------+------------+-------------+--------------+--------------
    <3000           |                   49 |         40 |           9 |         81.6 |          13.9
    3000-5999       |                   80 |         68 |          12 |         85.0 |          13.5
    6000-8999       |                   24 |         19 |           5 |         79.2 |          14.7
    9000-11999      |                   16 |         14 |           2 |         87.5 |          12.5
    12000+          |                   16 |         14 |           2 |         87.5 |           9.8
    (5 rows)
  6. What came of it

    The memo and the queries that followed; links found by title, so inferred

    It builds on:

  7. It becomes benchmark query q183

    Changed only where the benchmark needs the same result on every system

    • spellingcast (2×)
    • spellingfetch first
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision; bare NUMERIC cast that only served the rounding removed
    • numericsdocs/NUMERICS.md: EXTRACT(EPOCH FROM (b - a)) cast to NUMERIC(18,6)
    • made explicita selected t.col gets AS col, its standard name made explicit (5×)
    • placeholderseconds between
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results (2×)
    • placeholderfetch first
    The benchmark text, against the submitted SQL
    WITH pipelines_filtered AS (  SELECT p.id AS pipeline_id, p.tenant_id  SELECT p.id AS pipeline_id, p.tenant_id AS tenant_id  FROM pipelines p  JOIN tenants t ON t.id = p.tenant_id  WHERE t.name IN ('Ballycorran Industrial Supply','Castell Fasteners','Hanse Industriebedarf','Midland Safety Wear','Polder Packaging')    AND p.name = 'New business'),opp AS (  SELECT    o.id,    o.is_won,    o.created_at,    o.closed_at,    o.id AS id,    o.is_won AS is_won,    o.created_at AS created_at,    o.closed_at AS closed_at,    CASE WHEN o.currency_code = 'EUR' THEN o.amount         ELSE o.amount / (           SELECT er.rate           FROM exchange_rates er           WHERE er.base_code = 'EUR'             AND er.quote_code = o.currency_code             AND er.as_of <= o.closed_at::date           ORDER BY er.as_of DESC           LIMIT 1             AND er.as_of <= CAST(o.closed_at AS date)           ORDER BY er.as_of DESC NULLS FIRST           {{fetch_first(1)}}         )    END AS amount_eur  FROM opportunities o  JOIN pipelines_filtered pf ON pf.pipeline_id = o.pipeline_id AND pf.tenant_id = o.tenant_id  WHERE o.is_closed = true),banded AS (  SELECT    CASE      WHEN amount_eur < 3000 THEN '<3000'      WHEN amount_eur < 6000 THEN '3000-5999'      WHEN amount_eur < 9000 THEN '6000-8999'      WHEN amount_eur < 12000 THEN '9000-11999'      ELSE '12000+'    END AS amount_band_eur,    CASE      WHEN amount_eur < 3000 THEN 1      WHEN amount_eur < 6000 THEN 2      WHEN amount_eur < 9000 THEN 3      WHEN amount_eur < 12000 THEN 4      ELSE 5    END AS band_sort,    is_won,    closed_at,    created_at  FROM opp)SELECT  amount_band_eur,  COUNT(*) AS opportunities_closed,  COUNT(*) FILTER (WHERE is_won) AS closed_won,  COUNT(*) FILTER (WHERE NOT is_won) AS closed_lost,  ROUND(100.0 * COUNT(*) FILTER (WHERE is_won) / COUNT(*), 1) AS win_rate_pct,  ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 86400.0)::numeric, 1) AS avg_days_open  100.0 * COUNT(*) FILTER (WHERE is_won) / COUNT(*) AS win_rate_pct,  AVG({{seconds_between(created_at, closed_at)}} / 86400.0) AS avg_days_openFROM bandedGROUP BY amount_band_eur, band_sortORDER BY band_sortORDER BY band_sort NULLS LAST
    WITH pipelines_filtered AS (
      SELECT p.id AS pipeline_id, p.tenant_id AS tenant_id
      FROM pipelines p
      JOIN tenants t ON t.id = p.tenant_id
      WHERE t.name IN ('Ballycorran Industrial Supply','Castell Fasteners','Hanse Industriebedarf','Midland Safety Wear','Polder Packaging')
        AND p.name = 'New business'
    ),
    opp AS (
      SELECT
        o.id AS id,
        o.is_won AS is_won,
        o.created_at AS created_at,
        o.closed_at AS closed_at,
        CASE WHEN o.currency_code = 'EUR' THEN o.amount
             ELSE o.amount / (
               SELECT er.rate
               FROM exchange_rates er
               WHERE er.base_code = 'EUR'
                 AND er.quote_code = o.currency_code
                 AND er.as_of <= CAST(o.closed_at AS date)
               ORDER BY er.as_of DESC NULLS FIRST
               FETCH FIRST 1 ROWS ONLY
             )
        END AS amount_eur
      FROM opportunities o
      JOIN pipelines_filtered pf ON pf.pipeline_id = o.pipeline_id AND pf.tenant_id = o.tenant_id
      WHERE o.is_closed = true
    ),
    banded AS (
      SELECT
        CASE
          WHEN amount_eur < 3000 THEN '<3000'
          WHEN amount_eur < 6000 THEN '3000-5999'
          WHEN amount_eur < 9000 THEN '6000-8999'
          WHEN amount_eur < 12000 THEN '9000-11999'
          ELSE '12000+'
        END AS amount_band_eur,
        CASE
          WHEN amount_eur < 3000 THEN 1
          WHEN amount_eur < 6000 THEN 2
          WHEN amount_eur < 9000 THEN 3
          WHEN amount_eur < 12000 THEN 4
          ELSE 5
        END AS band_sort,
        is_won,
        closed_at,
        created_at
      FROM opp
    )
    SELECT
      amount_band_eur,
      COUNT(*) AS opportunities_closed,
      COUNT(*) FILTER (WHERE is_won) AS closed_won,
      COUNT(*) FILTER (WHERE NOT is_won) AS closed_lost,
      100.0 * COUNT(*) FILTER (WHERE is_won) / COUNT(*) AS win_rate_pct,
      AVG(CAST(EXTRACT(EPOCH FROM (closed_at - created_at)) AS NUMERIC(18,6)) / 86400.0) AS avg_days_open
    FROM banded
    GROUP BY amount_band_eur, band_sort
    ORDER BY band_sort NULLS LAST
    100 ms1 sHyper551 msDuckDB245 msGizmoSQL247 msclickhouse_brew135 msFirebolterrorDataFusionerrorClickHouseerror
    • Firebolt{ "errors": [ { "description": "Line 15, Column 24: Operations between decimals with different precision and scale is not supported at the moment. Explicitly cast one of the decimals to other decimal precision and scale." } ], "query": { "query_id": "a4b72f0a-c2a2-4a7b-afb6-0755f1fba3ce", "query_lab
    • DataFusionThis feature is not implemented: Physical plan does not support logical expression ScalarSubquery(<subquery>)
    • ClickHouseCode: 48. DB::Exception: Cannot decorrelate query, because 'Limit' step is not supported. (NOT_IMPLEMENTED)
  8. Everything, as recorded

    The agents' own records, as the role play wrote them; only the personal data of the person who ran it is redacted

    Omar's whole session, 7 turns
    • The request, as Omar received it
      Report request from Daniel Kim (analyst for the Head of B2B Commerce):
      
      Title: B2B Wholesale: Win Rate and Cycle Time by Deal-Size Band (New Business)
      Question: Does a bigger deal size correlate with a better or worse chance of closing, and does it drag out the cycle time?
      Grain: one row per deal-size band (based on EUR-converted opportunity amount)
      Columns:
      - amount_band_eur
      - opportunities_closed
      - closed_won
      - closed_lost
      - win_rate_pct
      - avg_days_open
      Details: Prompted by an odd pattern in an earlier won-vs-lost comparison: lost New-business opportunities had a LOWER average amount (5628.59 EUR) than won ones (7097.90 EUR), but a HIGHER median amount (6520.14 vs 5880.52 EUR) — suggesting the amount distribution isn't simply 'bigger deals win more' and deserves a direct bucket breakdown rather than just an average. Filter: tenants.name IN ('Ballycorran Industrial Supply','Castell Fasteners','Hanse Industriebedarf','Midland Safety Wear','Polder Packaging'), pipeline_id = each tenant's 'New business' pipeline, is_closed = true. Convert amount to EUR per opportunity: if currency_code = 'EUR', amount_eur = amount; otherwise amount_eur = amount / rate from exchange_rates (base_code='EUR', quote_code=currency_code, as_of nearest date on or before closed_at). Bucket amount_eur into: '<3000', '3000-5999', '6000-8999', '9000-11999', '12000+'. For each band: count of opportunities closed, closed won, closed lost, win_rate_pct, and avg_days_open (avg of closed_at - created_at in days). Order by band ascending (by lower bound).
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Omar calls dry_run
      WITH pipelines_filtered AS (
        SELECT p.id AS pipeline_id, p.tenant_id
        FROM pipelines p
        JOIN tenants t ON t.id = p.tenant_id
        WHERE t.name IN ('Ballycorran Industrial Supply','Castell Fasteners','Hanse Industriebedarf','Midland Safety Wear','Polder Packaging')
          AND p.name = 'New business'
      ),
      opp AS (
        SELECT
          o.id,
          o.is_won,
          o.created_at,
          o.closed_at,
          CASE WHEN o.currency_code = 'EUR' THEN o.amount
               ELSE o.amount / (
                 SELECT er.rate
                 FROM exchange_rates er
                 WHERE er.base_code = 'EUR'
                   AND er.quote_code = o.currency_code
                   AND er.as_of <= o.closed_at::date
                 ORDER BY er.as_of DESC
                 LIMIT 1
               )
          END AS amount_eur
        FROM opportunities o
        JOIN pipelines_filtered pf ON pf.pipeline_id = o.pipeline_id AND pf.tenant_id = o.tenant_id
        WHERE o.is_closed = true
      ),
      banded AS (
        SELECT
          CASE
            WHEN amount_eur < 3000 THEN '<3000'
            WHEN amount_eur < 6000 THEN '3000-5999'
            WHEN amount_eur < 9000 THEN '6000-8999'
            WHEN amount_eur < 12000 THEN '9000-11999'
            ELSE '12000+'
          END AS amount_band_eur,
          CASE
            WHEN amount_eur < 3000 THEN 1
            WHEN amount_eur < 6000 THEN 2
            WHEN amount_eur < 9000 THEN 3
            WHEN amount_eur < 12000 THEN 4
            ELSE 5
          END AS band_sort,
          is_won,
          closed_at,
          created_at
        FROM opp
      )
      SELECT
        amount_band_eur,
        COUNT(*) AS opportunities_closed,
        COUNT(*) FILTER (WHERE is_won) AS closed_won,
        COUNT(*) FILTER (WHERE NOT is_won) AS closed_lost,
        ROUND(100.0 * COUNT(*) FILTER (WHERE is_won) / COUNT(*), 1) AS win_rate_pct,
        ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 86400.0)::numeric, 1) AS avg_days_open
      FROM banded
      GROUP BY amount_band_eur, band_sort
      ORDER BY band_sort
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: amount_band_eur, opportunities_closed, closed_won, closed_lost, win_rate_pct, avg_days_open
    • OmarQuery compiles and returns the expected columns. Submitting.
    • Omar calls submit
      WITH pipelines_filtered AS (
        SELECT p.id AS pipeline_id, p.tenant_id
        FROM pipelines p
        JOIN tenants t ON t.id = p.tenant_id
        WHERE t.name IN ('Ballycorran Industrial Supply','Castell Fasteners','Hanse Industriebedarf','Midland Safety Wear','Polder Packaging')
          AND p.name = 'New business'
      ),
      opp AS (
        SELECT
          o.id,
          o.is_won,
          o.created_at,
          o.closed_at,
          CASE WHEN o.currency_code = 'EUR' THEN o.amount
               ELSE o.amount / (
                 SELECT er.rate
                 FROM exchange_rates er
                 WHERE er.base_code = 'EUR'
                   AND er.quote_code = o.currency_code
                   AND er.as_of <= o.closed_at::date
                 ORDER BY er.as_of DESC
                 LIMIT 1
               )
          END AS amount_eur
        FROM opportunities o
        JOIN pipelines_filtered pf ON pf.pipeline_id = o.pipeline_id AND pf.tenant_id = o.tenant_id
        WHERE o.is_closed = true
      ),
      banded AS (
        SELECT
          CASE
            WHEN amount_eur < 3000 THEN '<3000'
            WHEN amount_eur < 6000 THEN '3000-5999'
            WHEN amount_eur < 9000 THEN '6000-8999'
            WHEN amount_eur < 12000 THEN '9000-11999'
            ELSE '12000+'
          END AS amount_band_eur,
          CASE
            WHEN amount_eur < 3000 THEN 1
            WHEN amount_eur < 6000 THEN 2
            WHEN amount_eur < 9000 THEN 3
            WHEN amount_eur < 12000 THEN 4
            ELSE 5
          END AS band_sort,
          is_won,
          closed_at,
          created_at
        FROM opp
      )
      SELECT
        amount_band_eur,
        COUNT(*) AS opportunities_closed,
        COUNT(*) FILTER (WHERE is_won) AS closed_won,
        COUNT(*) FILTER (WHERE NOT is_won) AS closed_lost,
        ROUND(100.0 * COUNT(*) FILTER (WHERE is_won) / COUNT(*), 1) AS win_rate_pct,
        ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 86400.0)::numeric, 1) AS avg_days_open
      FROM banded
      GROUP BY amount_band_eur, band_sort
      ORDER BY band_sort
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • OmarSubmitted. The report bands closed New-business opportunities (for the 5 named B2B tenants) by EUR-converted amount, showing opportunities closed, won/lost counts, win rate %, and average days-open per band — directly testing whether larger deals correlate with better or worse win rates and longer cycle times.