text: import re
  import datetime
  from dateutil._common import weekday
  import dateutil.relativedelta as rd
  import numpy as np
  from pyqstrat.holiday_calendars import Calendar, get_date_from_weekday
  from typing import Tuple, Mapping
  
  FUTURE_CODES_INT = {'F': 1, 'G': 2, 'H': 3, 'J': 4, 'K': 5, 'M': 6, 'N': 7, 'Q': 8, 'U': 9, 'V': 10, 'X': 11, 'Z': 12}
  FUTURES_CODES_INVERTED: Mapping[int, str] = {v: k for k, v in FUTURE_CODES_INT.items()}
  
  FUTURE_CODES_STR = {'F': 'jan', 'G': 'feb', 'H': 'mar', 'J': 'apr', 'K': 'may', 'M': 'jun', 
                      'N': 'jul', 'Q': 'aug', 'U': 'sep', 'V': 'oct', 'X': 'nov', 'Z': 'dec'}
  
  
  def future_code_to_month(future_code: str) -> str:
      '''
      Given a future code such as "X", return the month abbreviation, such as "nov"
      
      Args:
          future_code (str): the one letter future code
          
      >>> future_code_to_month('X')
      'nov'
      '''
      assert len(future_code) == 1, f'Future code must be a single character: {future_code}'
      if future_code not in FUTURE_CODES_STR: raise Exception(f'unknown future code: {future_code}')
      return FUTURE_CODES_STR[future_code]
      
      if future_code not in FUTURE_CODES_INT: raise Exception(f'unknown future code: {future_code}')
      return FUTURE_CODES_INT[future_code]
  
  
  def future_code_to_month_number(future_code: str) -> int:
      '''
      Given a future code such as "X", return the month number (from 1 - 12)
      
      Args:
          future_code (str): the one letter future code
          
      >>> future_code_to_month_number('X')
      11
      '''
      assert len(future_code) == 1, f'Future code must be a single character: {future_code}'
      if future_code not in FUTURE_CODES_INT: raise Exception(f'unknown future code: {future_code}')
      return FUTURE_CODES_INT[future_code]
  
  
  def get_future_code(month: int) -> str:
      '''
      Given a month number such as 3 for March, return the future code for it, e.g. H
      >>> get_future_code(3)
      'H'
      '''
      return FUTURES_CODES_INVERTED[month]
  
  
  class EminiFuture:
      
      calendar = Calendar.get_calendar(Calendar.NYSE)
  
      @staticmethod
      def get_current_symbol(curr_date: datetime.date) -> str:
          '''
          >>> assert(EminiFuture.get_current_symbol(datetime.date(2019, 3, 14)) == 'ESH9')
          >>> assert(EminiFuture.get_current_symbol(datetime.date(2019, 3, 15)) == 'ESM9')
          >>> assert(EminiFuture.get_current_symbol(datetime.date(2020, 3, 14)) == 'ESH0')
          '''
          year = curr_date.year
          month = curr_date.month
          day = curr_date.day
          third_friday = EminiFuture.calendar.third_friday_of_month(month, year).astype(datetime.date)
          if month < 3 or (month == 3 and day < third_friday.day): month_str = 'H'
          elif month < 6 or (month == 6 and day < third_friday.day): month_str = 'M'
          elif month < 9 or (month == 9 and day < third_friday.day): month_str = 'U'
          elif month < 12 or (month == 12 and day < third_friday.day): month_str = 'Z'
          else:
              month_str = 'H'
              year += 1
          base = 2010 if year < 2020 else 2020
          fut_symbol = 'ES' + month_str + str(year - base)
          return fut_symbol
  
      @staticmethod
      def get_previous_symbol(curr_future_symbol: str) -> str:
          '''
          >>> assert(EminiFuture.get_previous_symbol('ESH9') == 'ESZ8')
          '''
          month = curr_future_symbol[2]
          year = int(curr_future_symbol[3])
          prev_month = {'H': 'Z', 'M': 'H', 'U': 'M', 'Z': 'U'}[month]
          prev_year = year if prev_month != 'Z' else year - 1
          if prev_year == -1: prev_year == 9
          return f'ES{prev_month}{prev_year}'
  
      @staticmethod
      def get_next_symbol(curr_future_symbol: str) -> str:
          '''
          >>> assert(EminiFuture.get_next_symbol('ESZ8') == 'ESH9')
          '''
          month = curr_future_symbol[2]
          year = int(curr_future_symbol[3])
          next_month = {'Z': 'H', 'H': 'M', 'M': 'U', 'U': 'Z'}[month]
          next_year = year if next_month != 'H' else year + 1
          if next_year == 10: next_year == 0
          return f'ES{next_month}{next_year}'
      
      @staticmethod
      def get_expiry(fut_symbol: str) -> np.datetime64:
          '''
          >>> assert(EminiFuture.get_expiry('ESH8') == np.datetime64('2018-03-16T08:30'))
          '''
          month_str = fut_symbol[-2: -1]
          year_str = fut_symbol[-1:]
          
          month = future_code_to_month_number(month_str)
          assert(isinstance(month, int))
          year = int(year_str)
          year_base = 2020 if year < 5 else 2010
          year = year_base + int(year_str)
          expiry_date = EminiFuture.calendar.third_friday_of_month(month, year).astype(datetime.date)
          return np.datetime64(expiry_date) + np.timedelta64(8 * 60 + 30, 'm')
      
  
  class EminiOption:
      
      calendar = Calendar.get_calendar(Calendar.NYSE)
  
      @staticmethod
      def decode_symbol(name: str) -> Tuple[weekday, int, int, int]:
          '''
          >>> EminiOption.decode_symbol('E1AF8')
          (MO, 2018, 1, 1)
          '''
          if re.match('EW[1-4].[0-9]', name):  # Friday
              year = int('201' + name[-1:])
              if year in [2010, 2011]: year += 10
              week = int(name[2:3])
              month = future_code_to_month_number(name[3:4])
              return rd.FR, year, month, week
          if re.match('E[1-5]A.[0-9]', name):  # Monday
              year = int('201' + name[-1:])
              if year in [2010, 2011]: year += 10
              week = int(name[1:2])
              month = future_code_to_month_number(name[3:4])
              return rd.MO, year, month, week
          if re.match('E[1-5]C.[0-9]', name):  # Wednesday
              year = int('201' + name[-1:])
              if year in [2010, 2011]: year += 10
              week = int(name[1:2])
              month = future_code_to_month_number(name[3:4])
              return rd.WE, year, month, week
          if re.match('EW[A-Z][0-9]', name):  # End of month
              year = int('201' + name[-1:])
              if year in [2010, 2011]: year += 10
              week = -1
              month = future_code_to_month_number(name[2:3])
              return rd.WE, year, month, week
          else:
              raise Exception(f'could not decode: {name}')
              
      @staticmethod
      def get_expiry(symbol: str) -> np.datetime64:
          '''
          >>> EminiOption.get_expiry('EW2Z5')
          numpy.datetime64('2015-12-11T15:00')
          >>> EminiOption.get_expiry('E3AF7')
          numpy.datetime64('2017-01-17T15:00')
          >>> EminiOption.get_expiry('EWF0')
          numpy.datetime64('2020-01-31T15:00')
          '''
          assert ':' not in symbol, f'{symbol} contains: pass in option root instead'
          weekday, year, month, week = EminiOption.decode_symbol(symbol)
          expiry = get_date_from_weekday(weekday.weekday, year, month, week)
          if weekday in [rd.WE, rd.FR]:
              expiry = EminiOption.calendar.add_trading_days(expiry, num_days=0, roll='backward')
          else:
              expiry = EminiOption.calendar.add_trading_days(expiry, num_days=0, roll='forward')
          # Option expirations changed on 9/20/2015 from 3:15 to 3 pm - 
          # See https://www.cmegroup.com/market-regulation/files/15-384.pdf
          expiry += np.where(expiry < np.datetime64('2015-09-20'), np.timedelta64(15 * 60 + 15, 'm'), np.timedelta64(15, 'h')) 
          return expiry
         
  
  if __name__ == "__main__":
      import doctest
      doctest.testmod(optionflags=doctest.NORMALIZE_WHITESPACE)
  