EAC Launch

Maddie Whittaker on 2023-11-03

Data Analyst

Margot Stevenard on 2023-11-03

Senior Data Analyst

The 2nd November marked the first day of delivery for EAC, the new platform launched by National Grid ESO for procuring ancillary services. This was a particularly exciting day for Arenko since it marked the beta-launch of our fully automated dynamic bidding tool.

The new platform brings 3 key changes:

  1. Negative pricing: Previously, the minimum price participants could bid was £0/MW/h. Now participants have the option to pay ESO to deliver a frequency service, i.e. price negatively.
  2. Stacking of services: In the past, participants had to choose to bid into only one frequency service per EFA day, so either Dynamic Containment (DC), Dynamic Moderation (DM) or Dynamic Regulation (DR). Now they can simultaneously offer and deliver all three services.
  3. Ability to submit multiple strategies: Participants can now submit numerous strategies and ESO will pick the strategy that best meets their requirements for that day.

In this post we look at how participants leveraged these new capabilities, and the impact it had on auction results.

Data

You can find the data used in this analysis in the NG ESO Data Portal: https://www.nationalgrideso.com/data-portal/eac-auction-results

results = pd.read_csv("content/notebooks/2023-11-03-eac/results.csv")
sell_orders = (
    pd.read_csv("content/notebooks/2023-11-03-eac/sell_orders.csv")
    .assign(efa=lambda x: x["deliveryStart"].rank(method="dense").astype("int"))
    .loc[lambda x: x["quantity"] > 0]
)

Negative Pricing

A major change to the dynamic services platform was to allow negative pricing. It might seem strange to pay to deliver a service, but here’s why it makes sense:

  • When delivering a frequency service, batteries get instructed to charge or discharge to maintain frequency levels on the grid.
  • In a high-frequency contract, batteries get instructed to charge when the grid frequency increases above 50Hz.
  • Under the rules of the service, batteries which are registered as balancing mechanism units don’t have to pay for the energy that they get instructed to charge. Hence you effectively get to charge your battery for free.
  • The more response there is, the more free energy you get to charge your assets with, and the more profitable this mechanism is. For that reason, DRH as the highest response service, is the most profitable service and participants are willing to pay a fee to deliver it.

Before EAC, most participants would bid into DRH at £0/MW/h and the main method for making your bid more likely to be accepted was to create a synthetic negative price by additionally offering a low-side response as a looped order.

So… how low did we go?

  • The charts below show the distribution in DRH and DMH sell order prices.
  • For DRH, EFAs 3-6 cleared between -£3/MW/h and -£6/MW/h.
    • EFAs 1 and 2 were less valuable due to low wholesale prices in this period.
  • For DMH, EFAs 5 and 6 cleared marginally negative.
    • This is the most valuable time to charge for free - when wholesale prices are highest during the evening peak.
    • DMH is less valuable than DRH as you respond less in DM.
  • The lowest priced order was -£10.87/MW/h for DRH EFA 5 by Centrica.
for service in ["DRH", "DMH"]:
    fig, ax = plt.subplots(2, 3, figsize=(15, 8), sharex=True, sharey=True)

    i = 0
    j = 0
    for efa in [1, 2, 3, 4, 5, 6]:
        mask = (sell_orders["efa"] == efa) & (sell_orders["auctionProduct"] == service)
        sns.distplot(
            sell_orders.loc[mask, "priceLimit"],
            kde=False,
            ax=ax[j][i],
            color=ARENKO_COLOURS[0],
            label="Dist. of All Orders",
        )
        ax[j][i].axvline(sell_orders.loc[mask, "clearingPrice"].mean(), color=ARENKO_COLOURS[2], label="Clearing Price")

        i += 1
        if i > 2:
            i = 0
            j += 1
    plt.suptitle(f"{service} Pricing")

png

png

Stacking Services

One major benefit of the new service is that providers do not need to choose between DC, DM and DR at the day-ahead stage: they can bid into all three services and let the market decide which contract they will be awarded.

So how many units took advantage of this from day 1?

Out of 109 units:

  • 52% of them submitted orders for at least two frequency response services.
  • 21% of units ended up winning a contract for at least two services.
sell_orders = sell_orders.assign(service_type=lambda x: x["auctionProduct"].str[:2])
y1 = (
    sell_orders.groupby("auctionUnit")["service_type"]
    .nunique()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Bid")
)

results = results.assign(service_type=lambda x: x["auctionProduct"].str[:2])
y2 = (
    results.loc[results["executedQuantity"] > 0]
    .groupby("auctionUnit")["service_type"]
    .nunique()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Won contract")
)

fig, ax = plt.subplots(figsize=(10, 5))
y1.join(y2).plot.bar(color=ARENKO_COLOURS, ax=ax)

png

How many chose to stack services within an EFA block?

Only 23% of units chose to submit a stacked order, and only 9% of units ended up winning stacked contracts.

y1 = (
    sell_orders.groupby(["auctionUnit", "basketID"])["service_type"]
    .nunique()
    .reset_index()
    .groupby("auctionUnit")
    .service_type.max()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Bid")
)

y2 = (
    results.loc[results["executedQuantity"] > 0]
    .groupby(["auctionUnit", "deliveryStart"])["service_type"]
    .nunique()
    .reset_index()
    .groupby("auctionUnit")["service_type"]
    .max()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Won contract")
)

fig, ax = plt.subplots(figsize=(10, 5))
y1.join(y2).plot.bar(color=ARENKO_COLOURS, ax=ax)

png

Ability to submit multiple strategies

How is this done?

The new service allows us to submit up to 25 “baskets”. A basket is a set of orders for a given EFA block. Each basket may contain up to 21 orders per frequency response service. This means each unit can submit a maximum of 21 x 6 x 25 = 3150 orders per EFA day! This is a huge increase in complexity and may give automated solutions a real advantage.

What did participants do?

Not one single unit submitted the maximum number of baskets (25); the maximum submitted was 24.

The chart below shows the number of baskets for all units that participated in the first EAC auction:

  • Most units did not use the full complexity available to them, only 18% of units submitted 24 baskets.
  • Most units submitted the same number of baskets per EFA.
fig, ax = plt.subplots(figsize=(10, 5))
sell_orders.groupby(["auctionUnit", "efa"])["basketID"].nunique().reset_index().pivot(
    index="auctionUnit", columns="efa", values="basketID"
).assign(total=lambda x: x.sum(axis=1)).sort_values(by="total").drop(columns="total").plot.bar(
    stacked=True, ax=ax, cmap="Blues"
)

png

This next chart shows the maximum number of orders in a basket per unit. The maximum submitted was 16, by one of our very own units, Enderby! This is far less than the cap of 126.

fig, ax = plt.subplots(figsize=(10, 5))

sell_orders.groupby(["auctionUnit", "basketID"]).size().reset_index().groupby(["auctionUnit"]).max()[
    0
].sort_values().plot.bar(color=ARENKO_COLOURS[0])

png

Next we aggregate this information at the optimiser level. The chart below shows the average number of baskets and orders within a basket per optimiser.

It is nice to see Arenko at the top in terms of complexity with Tesla!

Of course, more complexity is not necessarily a good thing. The more strategies you submit, the less you are targeting a particular strategy that you believe will be the most valuable. However, we don’t know which market is going to clear highest so we cannot guarantee our acceptance. A range of strategies, varying in complexity, is an attempt to manage these risks and make the most money on average across the portfolio.

comp = [
    "EDF ENERGY CUSTOMERS LIMITED",
    "HABITAT ENERGY LIMITED",
    "ANESCO LIMITED",
    "FLEXITRICITY LIMITED",
    "TESLA MOTORS LIMITED",
    "STATKRAFT MARKETS GMBH",
    "ARENKO CLEANTECH LIMITED",
]

mask = (sell_orders["registeredAuctionParticipant"].isin(comp)) & (sell_orders["quantity"] > 0)
num_orders = sell_orders.loc[mask].groupby(["registeredAuctionParticipant"]).size().to_frame(name="number_orders")
num_units = (
    sell_orders.loc[mask]
    .groupby(["registeredAuctionParticipant"])["auctionUnit"]
    .nunique()
    .to_frame(name="number_units")
)
num_baskets = (
    sell_orders.loc[mask]
    .groupby(["registeredAuctionParticipant"])["basketID"]
    .nunique()
    .to_frame(name="number_baskets")
)

summ = num_orders.join(num_units).join(num_baskets)

summ["avg. baskets per unit"] = summ["number_baskets"] / summ["number_units"]
summ["avg. orders per basket"] = summ["number_orders"] / summ["number_baskets"]

fig, ax = plt.subplots(figsize=(10, 5))
ax1 = ax.twinx()
summ.sort_values(by="avg. baskets per unit", ascending=False)["avg. baskets per unit"].plot.bar(
    ax=ax, color=ARENKO_COLOURS[0]
)
summ.sort_values(by="avg. baskets per unit", ascending=False)["avg. orders per basket"].plot.line(
    ax=ax1, color=ARENKO_COLOURS[2]
)

png

Summary

EAC kicked off with a bang:

  • From day 1 we saw services clearing at negative prices
  • Bids and executed contracts stacking services
  • Optimisers submitting multiple strategy options for each unit.

It will be interesting to see how the market evolves over the next few weeks.


For your convenience, here’s the code in one block:

results = pd.read_csv("content/notebooks/2023-11-03-eac/results.csv")
sell_orders = (
    pd.read_csv("content/notebooks/2023-11-03-eac/sell_orders.csv")
    .assign(efa=lambda x: x["deliveryStart"].rank(method="dense").astype("int"))
    .loc[lambda x: x["quantity"] > 0]
)
for service in ["DRH", "DMH"]:
    fig, ax = plt.subplots(2, 3, figsize=(15, 8), sharex=True, sharey=True)

    i = 0
    j = 0
    for efa in [1, 2, 3, 4, 5, 6]:
        mask = (sell_orders["efa"] == efa) & (sell_orders["auctionProduct"] == service)
        sns.distplot(
            sell_orders.loc[mask, "priceLimit"],
            kde=False,
            ax=ax[j][i],
            color=ARENKO_COLOURS[0],
            label="Dist. of All Orders",
        )
        ax[j][i].axvline(sell_orders.loc[mask, "clearingPrice"].mean(), color=ARENKO_COLOURS[2], label="Clearing Price")

        i += 1
        if i > 2:
            i = 0
            j += 1
    plt.suptitle(f"{service} Pricing")
sell_orders = sell_orders.assign(service_type=lambda x: x["auctionProduct"].str[:2])
y1 = (
    sell_orders.groupby("auctionUnit")["service_type"]
    .nunique()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Bid")
)

results = results.assign(service_type=lambda x: x["auctionProduct"].str[:2])
y2 = (
    results.loc[results["executedQuantity"] > 0]
    .groupby("auctionUnit")["service_type"]
    .nunique()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Won contract")
)

fig, ax = plt.subplots(figsize=(10, 5))
y1.join(y2).plot.bar(color=ARENKO_COLOURS, ax=ax)

y1 = (
    sell_orders.groupby(["auctionUnit", "basketID"])["service_type"]
    .nunique()
    .reset_index()
    .groupby("auctionUnit")
    .service_type.max()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Bid")
)

y2 = (
    results.loc[results["executedQuantity"] > 0]
    .groupby(["auctionUnit", "deliveryStart"])["service_type"]
    .nunique()
    .reset_index()
    .groupby("auctionUnit")["service_type"]
    .max()
    .replace(to_replace=[2, 3], value=">=2")
    .value_counts(sort=False)
    .to_frame(name="Won contract")
)

fig, ax = plt.subplots(figsize=(10, 5))
y1.join(y2).plot.bar(color=ARENKO_COLOURS, ax=ax)
fig, ax = plt.subplots(figsize=(10, 5))
sell_orders.groupby(["auctionUnit", "efa"])["basketID"].nunique().reset_index().pivot(
    index="auctionUnit", columns="efa", values="basketID"
).assign(total=lambda x: x.sum(axis=1)).sort_values(by="total").drop(columns="total").plot.bar(
    stacked=True, ax=ax, cmap="Blues"
)
fig, ax = plt.subplots(figsize=(10, 5))

sell_orders.groupby(["auctionUnit", "basketID"]).size().reset_index().groupby(["auctionUnit"]).max()[
    0
].sort_values().plot.bar(color=ARENKO_COLOURS[0])

comp = [
    "EDF ENERGY CUSTOMERS LIMITED",
    "HABITAT ENERGY LIMITED",
    "ANESCO LIMITED",
    "FLEXITRICITY LIMITED",
    "TESLA MOTORS LIMITED",
    "STATKRAFT MARKETS GMBH",
    "ARENKO CLEANTECH LIMITED",
]

mask = (sell_orders["registeredAuctionParticipant"].isin(comp)) & (sell_orders["quantity"] > 0)
num_orders = sell_orders.loc[mask].groupby(["registeredAuctionParticipant"]).size().to_frame(name="number_orders")
num_units = (
    sell_orders.loc[mask]
    .groupby(["registeredAuctionParticipant"])["auctionUnit"]
    .nunique()
    .to_frame(name="number_units")
)
num_baskets = (
    sell_orders.loc[mask]
    .groupby(["registeredAuctionParticipant"])["basketID"]
    .nunique()
    .to_frame(name="number_baskets")
)

summ = num_orders.join(num_units).join(num_baskets)

summ["avg. baskets per unit"] = summ["number_baskets"] / summ["number_units"]
summ["avg. orders per basket"] = summ["number_orders"] / summ["number_baskets"]

fig, ax = plt.subplots(figsize=(10, 5))
ax1 = ax.twinx()
summ.sort_values(by="avg. baskets per unit", ascending=False)["avg. baskets per unit"].plot.bar(
    ax=ax, color=ARENKO_COLOURS[0]
)
summ.sort_values(by="avg. baskets per unit", ascending=False)["avg. orders per basket"].plot.line(
    ax=ax1, color=ARENKO_COLOURS[2]
)