Source code for simply.market_fair

import pandas as pd
from typing import List
from time import time

from simply.market import Market, filter_orders
import simply.config as cfg
from simply.util import round_price


[docs]def time_it(function, timers={}): """Decorator function to time the duration and number of function calls. :param function: function do be decorated :type function: function :param timers: storage for cumulated time and call number :type timers: dict :return: decorated function or timer if given function is None :rtype: function or dict """ if function == "flush": keys = [key for key in timers.keys()] for key in keys: del timers[key] return if function: def decorated_function(*this_args, **kwargs): key = function.__name__ start_time = time() return_value = function(*this_args, **kwargs) delta_time = time() - start_time try: timers[key]["time"] += delta_time timers[key]["calls"] += 1 except KeyError: timers[key] = dict(time=0, calls=1) timers[key]["time"] += delta_time return return_value return decorated_function sorted_timer = dict(sorted(timers.items(), key=lambda x: x[1]["time"] / x[1]["calls"])) return sorted_timer
[docs]class BestCluster: """Class which keeps track of attributes resolving around a cluster and implements functionality to keep the best algorithm more readable. A single BestCluster contains the bids of this cluster, the asks which are matched at the current state with these bids, a copy of unmatched asks, its own clearing price, and offers methods like getting possible profits if asks would be inserted""" def __init__(self, idx, bestmarket: "BestMarket"): self.market: BestMarket = bestmarket self.idx = idx self.bids: pd.DataFrame = pd.DataFrame() self.asks: pd.DataFrame = pd.DataFrame() self.matches = [] self.clearing_price: float = -float("inf") self.bid_clearing_price: float = None self.clearing_price_reached: bool = False self.matched_energy_units: int = 0 self.ask_iterator = [] self._row: int def __repr__(self): return f"BestCluster {self.idx} with matched units: {self.matched_energy_units}, " \ f"clearing price: {self.clearing_price}"
[docs] @time_it def match_locally(self): clearing = get_clearing(self.bids, self.asks, prev_clearing_energy=self.matched_energy_units, ask_iterator=self.ask_iterator) self.matched_energy_units = clearing["matched_energy_units"] self.clearing_price = clearing["clearing_price"] self.bid_clearing_price = clearing["bid_clearing_price"]
[docs] def get_insertion_profit(self, ask) -> (float, dict): """Returns the profit for the ask if it were inserted, as well as the clearing dict""" # Get clearing using copies of the cluster's ask list and the inserted, modified ask ask_copy = ask.copy() ask_copy.adjusted_price = round_price(ask_copy.price + self.market.get_grid_fee( bid_cluster=self.idx, ask_cluster=ask_copy.cluster)) asks = self.asks.copy() asks.loc[ask_copy.name] = ask_copy asks = asks.sort_values(["adjusted_price", "price"], ascending=[True, False]) clearing = get_clearing(self.bids, asks) # The location / row number must be lower than the resulting clearing matched_energy. # If its higher it means it would not be matched. if asks.index.get_loc(ask_copy.name) + 1 > clearing["matched_energy_units"]: return -float("inf"), clearing # The clearing price should not drop due to insertion, as it makes other # profit driven decisions obsolete if self.clearing_price > clearing["clearing_price"]: return -float("inf"), clearing return round_price(clearing["clearing_price"] - ask_copy.adjusted_price), clearing
[docs] @time_it def remove(self, ask): if cfg.config.debug: print(f"removing {ask.name} from cluster {self.idx}") old_matched_energy = self.matched_energy_units self.asks = self.asks.drop(ask.name) # removing ask can change clearing. if the amount of energy stays the # same, this ask should be removed from other clusters, where it was # not matched before self.match_locally() if old_matched_energy == self.matched_energy_units: for cluster in self.market.clusters: if cluster == self: continue try: cluster.asks = cluster.asks.drop(ask.name) cluster.asks = cluster.asks.drop(ask.name) except KeyError: pass
[docs] def insert(self, ask, clearing): old_matched_energy = self.matched_energy_units old_clearing_price = self.clearing_price ask.adjusted_price = round_price(ask.price + self.market.get_grid_fee( bid_cluster=self.idx, ask_cluster=ask.cluster)) self.asks.loc[ask.name] = ask self.asks = self.asks.sort_values(["adjusted_price", "price"], ascending=[True, False]) # use the clearing which was calculated for insertion already self.matched_energy_units = clearing["matched_energy_units"] self.clearing_price = clearing["clearing_price"] self.bid_clearing_price = clearing["bid_clearing_price"] if old_matched_energy == self.matched_energy_units \ and old_clearing_price == self.clearing_price: # An ask was inserted but the matched energy stayed the same. In other words an old # matched ask got removed from matching in this cluster. therefore it becomes available # in other clusters ask = self.asks.iloc[self.matched_energy_units - 1] clusters = [cluster for cluster in self.market.clusters if cluster != self] dispute_value = -float("inf") best_profit = -float("inf") best_clearing, best_cluster, best_profit = \ self.market.get_best_cluster(dispute_value, best_profit, ask, clusters) if best_profit > 0: # TODO: # - disabled, as condition does not check if it decreases is already matched # volume in other clusters: # or best_profit == 0 and # best_clearing["matched_energy_units"] > best_cluster.matched_energy_units): # insert the ask if it generates profit # TODO: (disabled -> reevaluate): or if it at least increases the amount of # matched energy at 0 profit best_cluster.insert(ask, best_clearing) else: # best_profit is negative. therefore it will not be matched at this state. all other # clusters get this ask, for possible matching for cluster in clusters: cluster.asks[ask.name] = ask cluster.asks = cluster.asks.sort_values(["adjusted_price", "price"], ascending=[True, False]) elif old_matched_energy > clearing["matched_energy_units"]: # this should never happen raise Exception
[docs] def masked_asks(self): return self.asks.iloc[self.ask_iterator]
[docs] def get_lowest_ask(self): return self.asks.iloc[self.ask_iterator[self._row]]
[docs]class BestMarket(Market): """ Custom fair market mechanism. Similar to two-sided pay-as-clear, but searches globally for best matches, taking network fees into account.Nodes are first grouped into clusters (nodes with no transaction fees between them). Then, all clusters are evaluated individually, adding transaction fees to other clusters. If a match becomes disputed (order matched more than once), the higher offer is taken, while the other one is removed as a possible match and that cluster is re-evaluated. This converges to an optimal solution. If a network and a grid_fee_matrix parameter are both supplied, Market will favour grid_fee_matrix. """ def __init__(self, name=None, network=None, grid_fee_matrix=None, time_step=None, disputed_matching='grid_fee'): if network is not None and grid_fee_matrix is None: grid_fee_matrix = network.grid_fee_matrix super().__init__(name=name, network=network, grid_fee_matrix=grid_fee_matrix, time_step=time_step) self.clusters: List[BestCluster] = [] # ToDo: enum-type would be nicer than string self.disputed_matching = disputed_matching
[docs] def total_matched_energy_units(self): return sum([cluster.matched_energy_units for cluster in self.clusters])
[docs] @time_it def resolve_dispute(self, ask, bid_cluster): if self.disputed_matching == "grid_fee": # for dispute values bigger is better, therefore negative price return -self.get_grid_fee(bid_cluster=bid_cluster.idx, ask_cluster=ask.cluster) elif self.disputed_matching == "bid_price": try: asks = bid_cluster.asks.copy() ask = ask.copy() ask.adjusted_price = self.get_grid_fee(bid_cluster=bid_cluster.idx, ask_cluster=ask.cluster) asks[ask.name] = ask asks = asks.sort_values(["adjusted_price", "price"], ascending=[True, False]) ask_row = asks.index.get_loc(ask.name) val = bid_cluster.bids.iloc[bid_cluster.ask_iterator.index(ask_row)].price except (KeyError, IndexError, ValueError): val = -float("inf") return val raise ValueError
[docs] @time_it def clusters_to_match_exist(self): for cluster in self.clusters: if not cluster.clearing_price_reached: # at least on cluster still has not reached its clearing price return True return False
[docs] def get_clusters_to_match(self): return [cluster for cluster in self.clusters if not cluster.clearing_price_reached]
[docs] @time_it def match_old(self, show=False): asks = self.get_asks() bids = self.get_bids() # filter out market makers (infinite bus) and really large orders asks, asks_mm, bids, bids_mm, large_asks, large_bids = filter_orders(asks, bids) if (asks.empty and bids.empty) \ or (asks.empty and asks_mm.empty) \ or (bids.empty and bids_mm.empty): # no asks or bids at all: no matches return [] # filter out orders without cluster (can still be matched with market maker) asks = asks[~asks.cluster.isna()] bids = bids[~bids.cluster.isna()] # split asks and bids into the smallest energy unit, save original index, add cluster idx asks = self.split_orders_to_energy_unit(asks) bids = self.split_orders_to_energy_unit(bids) # keep track which clusters have to be (re)matched # start with all clusters clusters_to_match = set(range(len(self.grid_fee_matrix))) # keep track of matches matches = [] # keep track which asks to exclude in each zone (initially empty) exclude = {cluster_idx: set() for cluster_idx in clusters_to_match} while clusters_to_match: # simulate local market within cluster cluster_idx = clusters_to_match.pop() # get local bids _bids = bids[bids.cluster == cluster_idx] if len(_bids) == 0: # no bids within this cluster: can't match here continue # get all asks that are not in exclude list for this cluster (copy asks, retain index) _asks = asks.drop(exclude[cluster_idx], axis=0, inplace=False) # annotate asking price by grid-fee: # get cluster ID for all asks, preserve ordering ask_cluster_ids = list(_asks.cluster) # get grid_fees from any node in cluster to different ask actors grid_fees = self.grid_fee_matrix[cluster_idx] # get grid_fees in same order as ask node IDs ask_grid_fees = [grid_fees[i] for i in ask_cluster_ids] # set adjusted price with network grid-fee _asks["adjusted_price"] = pd.Series(_asks.price + ask_grid_fees, index=_asks.index) # order local bids and asks by price _bids = _bids.sort_values(["price"], ascending=False) _asks = _asks.sort_values(["adjusted_price", "price"], ascending=[True, False]) # match local bids and asks bid_iter = _bids.iterrows() bid_id, bid = next(bid_iter) _matches = [] for ask_id, ask in _asks.iterrows(): # compare with next bid if bid is not None: # still bids in queue if ask.adjusted_price <= bid.price: # bid and ask match: append to local solution _matches.append({ "time": self.t_step, "bid_id": bid_id, "ask_id": ask_id, "bid_actor": bid.actor_id, "ask_actor": ask.actor_id, "bid_cluster": bid.cluster, "ask_cluster": ask.cluster, "energy": cfg.config.energy_unit, "price": ask.adjusted_price, "included_grid_fee": ask.adjusted_price - ask.price }) # get next bid try: bid_id, bid = next(bid_iter) except (StopIteration, IndexError): bid = None # get next ask # remove old matches from same cluster matches = [m for m in matches if m["bid_cluster"] != cluster_idx] for _match in _matches: # adjust price to local market clearing price (highest asking price) _match["price"] = _matches[-1]["price"] # try to merge into global matches # remove double matches # asks are removed where market clearing price is lower for match_idx, match in enumerate(matches): if match["ask_id"] == _match["ask_id"]: # same ask: compare prices if _match["price"] > match["price"]: # new match is better: # exclude old match exclude[match["bid_cluster"]].add(match["ask_id"]) # replace old match matches[match_idx] = _match # redo other cluster clusters_to_match.add(match["bid_cluster"]) else: # old match is better: exclude new match exclude[cluster_idx].add(_match["ask_id"]) # redo current cluster clusters_to_match.add(cluster_idx) break else: # new match does not conflict: insert as-is matches.append(_match) matches = self.group_matches(asks, bids, matches) # match with market maker # find unmatched orders orders = self.orders[(self.orders["energy"] + cfg.config.EPS) > cfg.config.energy_unit] # ignore large orders orders = orders[~orders.index.isin(large_asks.index)] orders = orders[~orders.index.isin(large_bids.index)] # match asks only with bid market maker with highest price asks = orders[orders.type == 1] if not bids_mm.empty: # select bidding market maker by order ID, that has highest price bids_mm['price'] += cfg.config.default_grid_fee bid_mm_id = bids_mm['price'].astype(float).idxmax() bid_mm = bids_mm.loc[bid_mm_id] asks = asks[asks["price"] <= bid_mm.price] for ask_id, ask in asks.iterrows(): matches.append({ "time": self.t_step, "bid_id": bid_mm_id, "ask_id": ask_id, "bid_actor": bid_mm.actor_id, "ask_actor": ask.actor_id, "bid_cluster": bid_mm.cluster, "ask_cluster": ask.cluster, "energy": ask.energy, "price": bid_mm.price, "included_grid_fee": cfg.config.default_grid_fee }) # match bids only with ask market maker with lowest price bids = orders[orders.type == -1] if not asks_mm.empty: # select asking market maker by order ID, that has lowest price asks_mm['price'] += cfg.config.default_grid_fee ask_mm_id = asks_mm['price'].astype(float).idxmin() ask_mm = asks_mm.loc[ask_mm_id] # indices of matched bids equal order IDs respectively bids = bids[bids["price"] >= ask_mm.price] for bid_id, bid in bids.iterrows(): matches.append({ "time": self.t_step, "bid_id": bid_id, "ask_id": ask_mm_id, "bid_actor": bid.actor_id, "ask_actor": ask_mm.actor_id, "bid_cluster": bid.cluster, "ask_cluster": ask_mm.cluster, "energy": bid.energy, "price": ask_mm.price, "included_grid_fee": cfg.config.default_grid_fee }) if show: print(f"Matches: {matches}") output = self.add_grid_fee_info(matches) self.append_to_csv(output, 'matches.csv') return matches
[docs] @time_it def group_matches(self, asks, bids, matches): # group matches: ask -> bid -> match _matches = {} for match in matches: # get original order id of ask/bid and adjust order energy ask_id = match["ask_id"] ask_order_id = asks.loc[ask_id].order_id self.orders.loc[ask_order_id, "energy"] -= match["energy"] if cfg.config.debug: print(f'Order id {ask_order_id}: {self.orders.loc[ask_order_id, "energy"]}') assert self.orders.loc[ask_order_id, "energy"] + cfg.config.EPS >= 0, \ f"Volume of Order ID {ask_order_id} exceeded, e.g. by matched energy unit ask_id " \ f"{ask_id}." bid_id = match["bid_id"] bid_order_id = bids.loc[bid_id].order_id self.orders.loc[bid_order_id, "energy"] -= match["energy"] if ask_order_id not in _matches: # ask not seen before: create empty dict _matches[ask_order_id] = dict() ask_matches = _matches[ask_order_id] try: m = ask_matches[bid_order_id] # bid has already been matched with this ask: price has to be identical assert m["price"] == match["price"] m["energy"] += match["energy"] except KeyError: # new bid was matched m = match m["ask_id"] = ask_order_id m["bid_id"] = bid_order_id # update dictionary ask_matches[bid_order_id] = m # retrieve matches from nested dict matches = [m for ask_matches in _matches.values() for m in ask_matches.values()] for m in matches: m["energy"] = round_price(m["energy"]) m["price"] = round_price(m["price"]) m["included_grid_fee"] = round_price(m["included_grid_fee"]) return matches
[docs] @time_it def match(self, show=False): return self.match_new(show)
[docs] @time_it def match_new(self, show=False): asks = self.get_asks() bids = self.get_bids() bids.loc[:, "price"] = bids["price"].apply(lambda x: round(x, cfg.config.round_decimal)) # filter out market makers (infinite bus) and really large orders asks, asks_mm, bids, bids_mm, _, _ = filter_orders(asks, bids) if (asks.empty and bids.empty) \ or (asks.empty and asks_mm.empty) \ or (bids.empty and bids_mm.empty): # no asks or bids at all: no matches return [] mm_cluster = None if not asks_mm.empty: mm_cluster = asks_mm.iloc[0].cluster if not bids_mm.empty: assert mm_cluster == bids_mm.iloc[0].cluster # filter out actor orders without cluster asks = asks[~asks.cluster.isna()] bids = bids[~bids.cluster.isna()] # split asks and bids into the smallest energy unit, save original index, add cluster idx asks = self.split_orders_to_energy_unit(asks) bids = self.split_orders_to_energy_unit(bids) # Fill the orders up with MM bids/asks, so that MM could potentially match with totality # of posted orders. # Within each cluster only MarketMaker asks are copied into to be matched, # except the MarketMaker Cluster, where those MarketMaker will be filtered out. bids_mm.energy = cfg.config.energy_unit * len(asks) bids_mm = self.split_orders_to_energy_unit(bids_mm) asks_mm.energy = cfg.config.energy_unit * len(bids) asks_mm = self.split_orders_to_energy_unit(asks_mm) bids_mm.index = bids_mm.index + len(bids) asks_mm.index = asks_mm.index + len(asks) bids = pd.concat([bids, bids_mm]) asks = pd.concat([asks, asks_mm]) # if asks and bids are already equal in volume, there is no # keep track which clusters have to be (re)matched # start with all clusters: # - If market maker cluster is not contained in fee matrix, add it to the list additionally additional_cluster_idx = [None] * (mm_cluster is None) self.clusters = [BestCluster(idx=idx, bestmarket=self) for idx in list(range(len(self.grid_fee_matrix))) + additional_cluster_idx] # Work with copy to be able to remove empty bid clusters clusters_to_match = self.get_clusters_to_match() if cfg.config.debug: # prepare debug plotting import matplotlib.pyplot as plt fix, axs = plt.subplots(1, len(self.clusters)) cols = ["energy", "price"] i = 0 debug_clearing_price = pd.DataFrame(columns=[c.idx for c in clusters_to_match]) for cluster in clusters_to_match: # simulate local market within cluster # get local bids cluster.bids = bids[bids.cluster == cluster.idx] cluster.asks = asks.copy() if cluster.idx is mm_cluster: # This is the MM cluster, MM should not match with itself, i.e. filter out asks: if mm_cluster is None: # if MM cluster is None, it does not == cluster.idx even if it also is None assert cluster.bids.empty # set this Cluster with MM bids cluster.bids = bids[bids.cluster.isna()] # filter out MM asks cluster.asks = cluster.asks[~cluster.asks.cluster.isna()] else: # bids are already correctly filtered # filter out MM asks cluster.asks = cluster.asks[~(cluster.asks.cluster == mm_cluster)] if len(cluster.asks) == 0: # no asks within this cluster: can't match here # remove cluster from clusters to match cluster.ask_iterator = [] cluster.clearing_price_reached = True continue cluster.ask_iterator = [*range(0, len(cluster.asks))] # annotate asking price by grid-fee: # - correct floating point errors by rounding cluster.asks["adjusted_price"] = cluster.asks.apply( lambda row: row["price"] + self.get_grid_fee(bid_cluster=cluster.idx, ask_cluster=row["cluster"]) if pd.notnull(row["price"]) else pd.NA, axis=1 ).round(cfg.config.round_decimal) if len(cluster.bids) == 0: # no bids within this cluster: can't match here # remove cluster from clusters to match cluster.clearing_price_reached = True continue # order local bids and asks by price cluster.bids = cluster.bids.sort_values(["price"], ascending=False) cluster.asks = cluster.asks.sort_values(["adjusted_price", "price"], ascending=[True, False]) if cfg.config.debug: # plot orders and adjusted and masked asks per clusters ax = axs[i] ax.set_title(f"Cluster {cluster.idx}") ax.axis('off') order_book = pd.concat( [cluster.asks.reset_index()[cols+["adjusted_price"]].add_prefix("ask_"), cluster.bids.reset_index()[cols].add_prefix("bid_")], axis=1, ignore_index=True) colors = plt.cm.hot(255 - order_book.isna().mul(255).values) pd.plotting.table(ax, order_book, loc='center', cellLoc='center', colWidths=[0.2] * len(order_book), cellColours=colors) i += 1 # match local bids and asks cluster.match_locally() # Clusters without matches can be discarded for cluster in self.clusters: if cluster.matched_energy_units <= 0: cluster.clearing_price_reached = True ########################################################################################### # Cycle through the clusters. # Check the other clusters for the same ask # and remove the matches with lower profit. Make sure to change the clearing price # each cluster gets an iterator for its index to keep track on checked matches for cluster in self.clusters: cluster._row = 0 counter = 0 while self.clusters_to_match_exist(): if cfg.config.debug: print([c.clearing_price for c in self.clusters]) debug_clearing_price.loc[counter] = [c.clearing_price for c in self.clusters] clusters_to_match = self.get_clusters_to_match() lowest_ask = None bid_cluster = None # Find cluster with the lowest adjusted ask price (i.e. incl. fees) for c in clusters_to_match: try: ask = c.get_lowest_ask() except IndexError: c.clearing_price_reached = True c._row -= 1 continue # Replace bid cluster if cluster has lower price including fees # at its current row index if bid_cluster is None or lowest_ask.adjusted_price > ask.adjusted_price: lowest_ask = ask bid_cluster = c if bid_cluster is None: # No bid cluster found continue try: ask = lowest_ask # index compared to volume (index starts at 0 i.e. + 1) assert bid_cluster._row + 1 <= bid_cluster.matched_energy_units except (IndexError, AssertionError): bid_cluster.clearing_price_reached = True # row index was already above possible matching volume: undo bid_cluster._row -= 1 continue counter += 1 ask_id = ask.name best_match_cluster = self.find_best_profit_cluster(ask_id) assert best_match_cluster is not None # best cluster to match found. Remove matches from other clusters and adjust their # clearing price if cfg.config.debug: print( f"removing {ask_id} from " f"{[cluster.idx for cluster in self.clusters if cluster != best_match_cluster]}" ) self.remove_from_other_clusters(ask_id, best_match_cluster) try: # index compared to volume (index starts at 0 i.e. + 1) assert bid_cluster._row + 1 < bid_cluster.matched_energy_units except AssertionError: # If all energy in bid_cluster is already matched, already exclude from # clusters to be matched bid_cluster.clearing_price_reached = True # also do not increase ask row index continue if best_match_cluster == bid_cluster: # if the best match cluster was the current cluster, the row index should increment # if not the idx stays the same, since the element was removed. bid_cluster._row += 1 ########################################################################################### # So far asks, were not removed from dataframes but only from the index in # cluster.ask_iterator. This was done since removing single asks is computationally heavy. # The dataframes are now set to the remaining indices for cluster in self.clusters: cluster.asks = cluster.masked_asks() cluster.ask_iterator = [*range(0, len(cluster.asks))] ########################################################################################### # All asks for each cluster should be unique now # since ask went to the best clusters at a moment when the total matched energy was not # decided, the following part runs through all asks, and checks if moving them is profitable # for them # move ask around / insert them for as long as they find higher profit chances asks_changed = False # TODO remove temporary exclusion # ToDo Might want to have a counter which stops this loop if it does not converge. while asks_changed: asks_changed = False # List tuples (ask, bid_cluster), which has the lowest ask per ask cluster for each # (bid) cluster, and is sorted by price, e.g. 2 clusters with bids and asks each, would # result in a list of maximum 4 entries with 2 entries for each cluster bottom_asks = self.get_bottom_asks() for bottom_ask, bid_cluster in bottom_asks: current_profit = bid_cluster.clearing_price - bottom_ask.adjusted_price if current_profit < 0: continue dispute_value = self.resolve_dispute(bottom_ask, bid_cluster) clusters = [cluster for cluster in self.clusters if cluster != bid_cluster] best_clearing, best_cluster, best_profit = self.get_best_cluster( dispute_value, current_profit, bottom_ask, clusters) if best_cluster is None: # no better cluster found than the current one continue if cfg.config.debug: print([ (dispute_value, current_profit, bid_cluster.idx), (self.resolve_dispute(bottom_ask, best_cluster), best_profit, best_cluster.idx), best_clearing]) asks_changed = True bid_cluster.remove(bottom_ask) best_cluster.insert(bottom_ask, best_clearing) matches = [] for cluster in self.clusters: asks_ = cluster.asks bids_ = cluster.bids # at this point the matched energy unit should be correct already assert cluster.matched_energy_units == \ get_clearing(bids_, asks_)["matched_energy_units"] for i in range(cluster.matched_energy_units): ask = asks_.iloc[i] bid = bids_.iloc[i] matches.append({ "time": self.t_step, "bid_id": bid.name, "ask_id": ask.name, "bid_actor": bid.actor_id, "ask_actor": ask.actor_id, "bid_cluster": bid.cluster, "ask_cluster": ask.cluster, "energy": cfg.config.energy_unit, "price": cluster.clearing_price, "included_grid_fee": ask.adjusted_price - ask.price }) matches = self.group_matches(asks, bids, matches) if show: print("\n".join([str(cluster) for cluster in self.clusters])) output = self.add_grid_fee_info(matches) self.append_to_csv(output, 'matches.csv') return matches
[docs] @time_it def remove_from_other_clusters(self, ask_id, best_match_cluster): for cluster in self.clusters: if cluster == best_match_cluster: continue if cluster.idx is None: if ask_id not in cluster.asks.index: # Market Maker asks do not exist, i.e. are not considered # in Market Maker cluster continue ask_row = cluster.asks.index.get_loc(ask_id) try: if cfg.config.debug: print(f"remove row {ask_row} <-> ask_id {ask_id} from cluster {cluster.idx}: " f"{dict(cluster.asks.iloc[ask_row])}") cluster.ask_iterator.remove(ask_row) except ValueError: # ask_id not found. Already deleted continue cluster.match_locally()
[docs] @time_it def get_best_cluster(self, best_dispute_value, best_profit, ask, clusters): best_clearing = None # This will be replaced by a dictionary best_cluster = None # This will hold a cluster object not only an ID for cluster in clusters: grid_fee = self.get_grid_fee(bid_cluster=cluster.idx, ask_cluster=ask.cluster) if cluster.clearing_price - (ask.price + grid_fee) < best_profit: # insertion of an ask can only lower the profit. If even the upper bound can not # compete with current best profit, skipping the rest increases function speed continue insertion_profit, clearing = cluster.get_insertion_profit(ask) dispute_value = self.resolve_dispute(ask, cluster) if (insertion_profit > best_profit or insertion_profit == best_profit and dispute_value > best_dispute_value): best_profit = insertion_profit best_cluster = cluster best_clearing = clearing best_dispute_value = dispute_value return best_clearing, best_cluster, best_profit
[docs] @time_it def get_bottom_asks(self): bottom_asks = [] for cluster in self.clusters: if cluster.matched_energy_units == 0: continue for _cluster in self.clusters: asks_with_cluster = cluster.asks[cluster.asks.cluster == _cluster.idx] if len(asks_with_cluster) > 0: bottom_asks.append((asks_with_cluster.iloc[0], cluster)) bottom_asks = sorted(bottom_asks, key=lambda x: x[0].price) return bottom_asks
[docs] def find_best_profit_cluster(self, ask_id): best_profit = float("-inf") best_dispute_value = -float("inf") best_match_cluster = None # find best cluster for this ask # best price AND also capacity to take the energy for cluster in self.get_clusters_to_match(): # Note: ask_price does not influence the best cluster, since the "pure" ask price is # the same for all clusters. The all_asks is still used to confirm the ask is # not deleted yet try: ask = cluster.masked_asks().loc[ask_id] except KeyError: continue # Checking if the ask is inside of the matching is not necessary here. Matches # are just placed in the most likely "good" position". Since all asks are checked # for better positioning in the end suboptimal placing here will be negated. profit = round_price(cluster.clearing_price - ask.adjusted_price) if cfg.config.debug: print(f"? BEST cluster {cluster.idx} profit: {profit} " f"({cluster.clearing_price} - {ask.adjusted_price})") dispute_value = self.resolve_dispute(ask, cluster) if ((profit > best_profit and profit >= 0) or (profit == best_profit and dispute_value > best_dispute_value)): best_profit = profit best_match_cluster = cluster best_dispute_value = dispute_value if cfg.config.debug: if best_match_cluster is not None: if best_match_cluster.idx is not None and ask_id not in best_match_cluster.asks: print("Market Maker does not contain Market Maker asks") print(f"Found BEST cluster {best_match_cluster.idx} profit: {best_profit} " f"({best_match_cluster.clearing_price} " f"- {best_match_cluster.asks.loc[ask_id].adjusted_price})") cols = ["actor_id", "order_id", "price", "cluster", "adjusted_price"] bids = best_match_cluster.bids.reset_index(drop=True) asks = best_match_cluster.masked_asks().reset_index(drop=True) test_df = pd.concat([bids[cols[:-2]], asks[cols]], axis=1) print(test_df.to_string()) return best_match_cluster
[docs] @time_it def split_orders_to_energy_unit(self, orders): orders = pd.DataFrame(orders) orders["order_id"] = orders.index orders = pd.DataFrame(orders.values.repeat( orders.energy * (1 / cfg.config.energy_unit), axis=0), columns=orders.columns) orders.energy = cfg.config.energy_unit return orders
[docs]@time_it def get_clearing(bids, asks, prev_clearing_energy: int = None, ask_iterator=None): clearing = dict() clearing["matched_energy_units"] = 0 clearing["clearing_price"] = -float("inf") clearing["bid_clearing_price"] = None start_row = 0 if asks.empty or bids.empty: return clearing if prev_clearing_energy: start_row = max(prev_clearing_energy - 5, 0) if ask_iterator is None: ask_iterator = [*range(start_row, len(bids))] for row in range(start_row, len(bids)): try: bid_price = bids.price.iloc[row] ask_price = asks.adjusted_price.iloc[ask_iterator[row]] except IndexError: return clearing if ask_price <= bid_price: clearing["matched_energy_units"] = row + 1 clearing["clearing_price"] = ask_price clearing["bid_clearing_price"] = bid_price else: return clearing return clearing