class TestMarketRedisEventPublisher(unittest.TestCase): def setUp(self): self.publisher = MarketRedisEventPublisher("test_id") def tearDown(self): pass def test_response_callback_stores_transaction_uuid(self): payload = { "data": json.dumps({ "response": {}, "transaction_uuid": "my_uuid" }) } self.publisher.response_callback(payload) assert "my_uuid" in self.publisher.event_response_uuids def test_publish_event_subscribes_to_response_and_publishes(self): offer = Offer("1", now(), 2, 3, "A") trade = Trade("2", now(), Offer("accepted", now(), 7, 8, "Z"), "B", "C") new_offer = Offer("3", now(), 4, 5, "D") existing_offer = Offer("4", now(), 5, 6, "E") kwargs = { "offer": offer, "trade": trade, "new_offer": new_offer, "existing_offer": existing_offer } self.publisher.publish_event(MarketEvent.OFFER, **kwargs) self.publisher.redis.sub_to_channel.assert_called_once_with( "market/test_id/notify_event/response", self.publisher.response_callback) expected_result = {k: v.to_JSON_string() for k, v in kwargs.items()} self.publisher.redis.publish.assert_called_once() assert self.publisher.redis.publish.call_args_list[0][0][0] == \ "market/test_id/notify_event" publish_call_args = json.loads( self.publisher.redis.publish.call_args_list[0][0][1]) assert publish_call_args["event_type"] == MarketEvent.OFFER.value assert len(DeepDiff(publish_call_args["kwargs"], expected_result)) == 0 def test_wait_for_event_response_calls_poll_method(self): self.publisher.event_response_uuids = ["test_uuid"] self.publisher._wait_for_event_response( {"transaction_uuid": "test_uuid"}) self.publisher.redis.poll_until_response_received.assert_called() assert "test_uuid" not in self.publisher.event_response_uuids
class Market: def __init__(self, time_slot=None, bc=None, notification_listener=None, readonly=False, transfer_fees: TransferFees = None, name=None): self.name = name self.bc = bc self.id = str(uuid.uuid4()) self.time_slot = time_slot self.time_slot_str = time_slot.format(DATE_TIME_FORMAT) \ if self.time_slot is not None \ else None self.readonly = readonly # offer-id -> Offer self.offers = {} # type: Dict[str, Offer] self.offer_history = [] # type: List[Offer] self.notification_listeners = [] self.bids = {} # type: Dict[str, Bid] self.bid_history = [] # type: List[Bid] self.trades = [] # type: List[Trade] self.transfer_fee_ratio = transfer_fees.transfer_fee_pct / 100 \ if transfer_fees is not None else 0 self.transfer_fee_const = transfer_fees.transfer_fee_const \ if transfer_fees is not None else 0 self.market_fee = 0 # Store trades temporarily until bc event has fired self.traded_energy = {} self.accumulated_actual_energy_agg = {} self.min_trade_price = sys.maxsize self._avg_trade_price = None self.max_trade_price = 0 self.min_offer_price = sys.maxsize self._avg_offer_price = None self.max_offer_price = 0 self.accumulated_trade_price = 0 self.accumulated_trade_energy = 0 if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS: self.redis_publisher = MarketRedisEventPublisher(self.id) elif notification_listener: self.notification_listeners.append(notification_listener) self.current_tick = 0 self.device_registry = DeviceRegistry.REGISTRY if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS: self.redis_api = MarketRedisEventSubscriber(self) \ if ConstSettings.IAASettings.MARKET_TYPE == 1 \ else TwoSidedMarketRedisEventSubscriber(self) setattr(self, RLOCK_MEMBER_NAME, RLock()) def add_listener(self, listener): self.notification_listeners.append(listener) def _notify_listeners(self, event, **kwargs): if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS: self.redis_publisher.publish_event(event, **kwargs) else: # Deliver notifications in random order to ensure fairness for listener in sorted(self.notification_listeners, key=lambda l: random()): listener(event, market_id=self.id, **kwargs) def _update_stats_after_trade(self, trade, offer, buyer, already_tracked=False): # FIXME: The following updates need to be done in response to the BC event # TODO: For now event driven blockchain updates have been disabled in favor of a # sequential approach, but once event handling is enabled this needs to be handled if not already_tracked: self.trades.append(trade) self._update_accumulated_trade_price_energy(trade) self.traded_energy = add_or_create_key(self.traded_energy, offer.seller, offer.energy) self.traded_energy = subtract_or_create_key(self.traded_energy, buyer, offer.energy) self._update_min_max_avg_trade_prices(offer.price / offer.energy) # Recalculate offer min/max price since offer was removed self._update_min_max_avg_offer_prices() def _update_accumulated_trade_price_energy(self, trade): self.accumulated_trade_price += trade.offer.price self.accumulated_trade_energy += trade.offer.energy def _update_min_max_avg_offer_prices(self): self._avg_offer_price = None offer_prices = [o.price / o.energy for o in self.offers.values()] if offer_prices: self.min_offer_price = round(min(offer_prices), 4) self.max_offer_price = round(max(offer_prices), 4) def _update_min_max_avg_trade_prices(self, price): self.max_trade_price = round(max(self.max_trade_price, price), 4) self.min_trade_price = round(min(self.min_trade_price, price), 4) self._avg_trade_price = None self._avg_offer_price = None def __repr__(self): # pragma: no cover return "<Market{} offers: {} (E: {} kWh V: {}) trades: {} (E: {} kWh, V: {})>".format( " {}".format(self.time_slot_str), len(self.offers), sum(o.energy for o in self.offers.values()), sum(o.price for o in self.offers.values()), len(self.trades), self.accumulated_trade_energy, self.accumulated_trade_price ) @staticmethod def sorting(obj, reverse_order=False): if reverse_order: # Sorted bids in descending order return list(reversed(sorted( obj.values(), key=lambda b: b.price / b.energy))) else: # Sorted bids in ascending order return list(sorted( obj.values(), key=lambda b: b.price / b.energy)) @property def avg_offer_price(self): if self._avg_offer_price is None: price = sum(o.price for o in self.offers.values()) energy = sum(o.energy for o in self.offers.values()) self._avg_offer_price = round(price / energy, 4) if energy else 0 return self._avg_offer_price @property def avg_trade_price(self): if self._avg_trade_price is None: price = self.accumulated_trade_price energy = self.accumulated_trade_energy self._avg_trade_price = round(price / energy, 4) if energy else 0 return self._avg_trade_price @property def sorted_offers(self): return self.sorting(self.offers) @property def most_affordable_offers(self): cheapest_offer = self.sorted_offers[0] rate = cheapest_offer.price / cheapest_offer.energy return [o for o in self.sorted_offers if abs(o.price / o.energy - rate) < FLOATING_POINT_TOLERANCE] def update_clock(self, current_tick): self.current_tick = current_tick @property def now(self) -> DateTime: return GlobalConfig.start_date.add( seconds=GlobalConfig.tick_length.seconds * self.current_tick) def set_actual_energy(self, time, reporter, value): if reporter in self.accumulated_actual_energy_agg: self.accumulated_actual_energy_agg[reporter] += value else: self.accumulated_actual_energy_agg[reporter] = value @property def actual_energy_agg(self): return self.accumulated_actual_energy_agg def bought_energy(self, buyer): return sum(trade.offer.energy for trade in self.trades if trade.buyer == buyer) def sold_energy(self, seller): return sum(trade.offer.energy for trade in self.trades if trade.offer.seller == seller) def total_spent(self, buyer): return sum(trade.offer.price for trade in self.trades if trade.buyer == buyer) def total_earned(self, seller): return sum(trade.offer.price for trade in self.trades if trade.seller == seller)
class Market: def __init__(self, time_slot=None, bc=None, notification_listener=None, readonly=False, grid_fee_type=ConstSettings.IAASettings.GRID_FEE_TYPE, grid_fees: GridFee = None, name=None): self.name = name self.bc_interface = bc self.id = str(uuid.uuid4()) self.time_slot = time_slot self.time_slot_str = time_slot.format(DATE_TIME_FORMAT) \ if self.time_slot is not None \ else None self.readonly = readonly # offer-id -> Offer self.offers = {} # type: Dict[str, Offer] self.offer_history = [] # type: List[Offer] self.notification_listeners = [] self.bids = {} # type: Dict[str, Bid] self.bid_history = [] # type: List[Bid] self.trades = [] # type: List[Trade] self.const_fee_rate = None self._create_fee_handler(grid_fee_type, grid_fees) self.market_fee = 0 # Store trades temporarily until bc event has fired self.traded_energy = {} self.min_trade_price = None self._avg_trade_price = None self.max_trade_price = None self.min_offer_price = None self._avg_offer_price = None self.max_offer_price = None self.accumulated_trade_price = 0 self.accumulated_trade_energy = 0 if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS: self.redis_publisher = MarketRedisEventPublisher(self.id) elif notification_listener: self.notification_listeners.append(notification_listener) self.current_tick_in_slot = 0 self.device_registry = DeviceRegistry.REGISTRY if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS: self.redis_api = MarketRedisEventSubscriber(self) \ if ConstSettings.IAASettings.MARKET_TYPE == 1 \ else TwoSidedMarketRedisEventSubscriber(self) setattr(self, RLOCK_MEMBER_NAME, RLock()) def _create_fee_handler(self, grid_fee_type, grid_fees): if not grid_fees: grid_fees = GridFee(grid_fee_percentage=0.0, grid_fee_const=0.0) if grid_fee_type == 1: if grid_fees.grid_fee_const is None or \ grid_fees.grid_fee_const <= 0.0: self.fee_class = ConstantGridFees(0.0) else: self.fee_class = ConstantGridFees(grid_fees.grid_fee_const) self.const_fee_rate = self.fee_class.grid_fee_rate else: if grid_fees.grid_fee_percentage is None or \ grid_fees.grid_fee_percentage <= 0.0: self.fee_class = GridFees(0.0) else: self.fee_class = GridFees(grid_fees.grid_fee_percentage / 100) @property def _is_constant_fees(self): return isinstance(self.fee_class, ConstantGridFees) @property def open_bids_and_offers(self): return self.bids, self.offers def add_listener(self, listener): self.notification_listeners.append(listener) def _notify_listeners(self, event, **kwargs): if ConstSettings.GeneralSettings.EVENT_DISPATCHING_VIA_REDIS: self.redis_publisher.publish_event(event, **kwargs) else: # Deliver notifications in random order to ensure fairness for listener in sorted(self.notification_listeners, key=lambda l: random()): listener(event, market_id=self.id, **kwargs) def _update_stats_after_trade(self, trade, offer_or_bid, already_tracked=False): # FIXME: The following updates need to be done in response to the BC event # TODO: For now event driven blockchain updates have been disabled in favor of a # sequential approach, but once event handling is enabled this needs to be handled if not already_tracked: self.trades.append(trade) self.market_fee += trade.fee_price self._update_accumulated_trade_price_energy(trade) self.traded_energy = \ add_or_create_key(self.traded_energy, trade.seller, offer_or_bid.energy) self.traded_energy = \ subtract_or_create_key(self.traded_energy, trade.buyer, offer_or_bid.energy) self._update_min_max_avg_trade_prices(offer_or_bid.energy_rate) # Recalculate offer min/max price since offer was removed self._update_min_max_avg_offer_prices() def _update_accumulated_trade_price_energy(self, trade): self.accumulated_trade_price += trade.offer_bid.price self.accumulated_trade_energy += trade.offer_bid.energy def _update_min_max_avg_offer_prices(self): self._avg_offer_price = None offer_prices = [o.energy_rate for o in self.offers.values()] if offer_prices: self.min_offer_price = round(min(offer_prices), 4) self.max_offer_price = round(max(offer_prices), 4) def _update_min_max_avg_trade_prices(self, price): self.max_trade_price = round(max(self.max_trade_price, price), 4) if self.max_trade_price \ else round(price, 4) self.min_trade_price = round(min(self.min_trade_price, price), 4) if self.min_trade_price \ else round(price, 4) self._avg_trade_price = None self._avg_offer_price = None def __repr__(self): # pragma: no cover return "<Market{} offers: {} (E: {} kWh V: {}) trades: {} (E: {} kWh, V: {})>".format( " {}".format(self.time_slot_str), len(self.offers), sum(o.energy for o in self.offers.values()), sum(o.price for o in self.offers.values()), len(self.trades), self.accumulated_trade_energy, self.accumulated_trade_price) @staticmethod def sorting(offers_bids: Dict, reverse_order=False) -> List[Union[Bid, Offer]]: """Sort a list of bids or offers by their energy_rate attribute.""" if reverse_order: # Sorted bids in descending order return list( reversed( sorted(offers_bids.values(), key=lambda obj: obj.energy_rate))) else: return sorted(offers_bids.values(), key=lambda obj: obj.energy_rate, reverse=reverse_order) @property def avg_offer_price(self): if self._avg_offer_price is None: price = sum(o.price for o in self.offers.values()) energy = sum(o.energy for o in self.offers.values()) self._avg_offer_price = round(price / energy, 4) if energy else 0 return self._avg_offer_price @property def avg_trade_price(self): if self._avg_trade_price is None: price = self.accumulated_trade_price energy = self.accumulated_trade_energy self._avg_trade_price = round(price / energy, 4) if energy else 0 return self._avg_trade_price @property def sorted_offers(self): return self.sorting(self.offers) @property def most_affordable_offers(self): cheapest_offer = self.sorted_offers[0] rate = cheapest_offer.energy_rate return [ o for o in self.sorted_offers if abs(o.energy_rate - rate) < FLOATING_POINT_TOLERANCE ] def update_clock(self, current_tick_in_slot): self.current_tick_in_slot = current_tick_in_slot @property def now(self) -> DateTime: return self.time_slot.add(seconds=GlobalConfig.tick_length.seconds * self.current_tick_in_slot) def bought_energy(self, buyer): return sum(trade.offer_bid.energy for trade in self.trades if trade.buyer == buyer) def sold_energy(self, seller): return sum(trade.offer_bid.energy for trade in self.trades if trade.seller == seller) def total_spent(self, buyer): return sum(trade.offer_bid.price for trade in self.trades if trade.buyer == buyer) def total_earned(self, seller): return sum(trade.offer_bid.price for trade in self.trades if trade.seller == seller) @property def info(self): return { "name": self.name, "id": self.id, "start_time": self.time_slot_str, "duration_min": GlobalConfig.slot_length.minutes } def get_bids_offers_trades(self): return { "bids": [b.serializable_dict() for b in self.bid_history], "offers": [o.serializable_dict() for o in self.offer_history], "trades": [t.serializable_dict() for t in self.trades] }