4848 r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})\s*-\s*(?P<eh>\d{1,2}):(?P<em>\d{2})$"
4949)
5050_DATE_RE = re .compile (r"(\d{2})-(\d{2})-(\d{4})" )
51+ _MISSING_PRICE_MARKERS = {"" , "-" , "NA" , "N/A" }
5152
5253
5354def _parse_delivery_date (text : str ) -> date :
@@ -97,31 +98,99 @@ def _block_bounds(delivery_date: date, time_block: str) -> tuple[datetime, datet
9798 return start , end
9899
99100
101+ def _find_dam_table (soup : BeautifulSoup ):
102+ """Find the DAM table and return it with the Time Block and MCP indexes."""
103+ for table in soup .find_all ("table" ):
104+ for tr in table .find_all ("tr" ):
105+ header_cells = tr .find_all (["th" , "td" ])
106+ if not header_cells :
107+ continue
108+ headers = [
109+ cell .get_text (" " , strip = True ).casefold () for cell in header_cells
110+ ]
111+ time_block_index = next (
112+ (i for i , header in enumerate (headers ) if "time block" in header ),
113+ None ,
114+ )
115+ mcp_index = next (
116+ (i for i , header in enumerate (headers ) if "mcp" in header ), None
117+ )
118+ if time_block_index is not None and mcp_index is not None :
119+ return table , time_block_index , mcp_index
120+
121+ raise ParserException (PARSER , "Missing DAM price table with Time Block and MCP columns" )
122+
123+
100124def _parse_dam_html (
101125 html : str , logger : Logger
102126) -> tuple [date , list [tuple [datetime , datetime , float ]]]:
103- """Parse provisional DAM HTML into (delivery_date, [(start, end, price), ...])."""
127+ """Parse provisional DAM HTML into (delivery_date, [(start, end, price), ...]).
128+
129+ IEX pages have used both a dated heading and row-spanned delivery-date cells.
130+ The parser supports both layouts and derives the MCP position relative to the
131+ Time Block column, so date and hour row spans before the time block do not
132+ shift the price lookup.
133+ """
104134 soup = BeautifulSoup (html , "html.parser" )
105135
136+ delivery_date : date | None = None
106137 heading = soup .find ("h1" )
107- if heading is None :
108- raise ParserException (PARSER , "Missing <h1> with delivery date on DAM page" )
109- delivery_date = _parse_delivery_date (heading .get_text (" " , strip = True ))
138+ if heading is not None :
139+ heading_text = heading .get_text (" " , strip = True )
140+ if _DATE_RE .search (heading_text ):
141+ delivery_date = _parse_delivery_date (heading_text )
110142
111- table = soup .find ("table" )
112- if table is None :
113- raise ParserException (PARSER , "Missing DAM price table" )
143+ table , time_block_column , mcp_column = _find_dam_table (soup )
144+ mcp_offset = mcp_column - time_block_column
114145
115146 rows : list [tuple [datetime , datetime , float ]] = []
116147 for tr in table .find_all ("tr" ):
117- cells = [c .get_text (strip = True ) for c in tr .find_all ("td" )]
118- if len ( cells ) < 2 :
148+ cells = [c .get_text (" " , strip = True ) for c in tr .find_all ("td" )]
149+ if not cells :
119150 continue
120- time_block , mcp_raw = cells [0 ], cells [1 ]
121- if not _TIME_BLOCK_RE .match (time_block ):
151+
152+ date_cell = next (
153+ (cell for cell in cells if _DATE_RE .fullmatch (cell .strip ())), None
154+ )
155+ if date_cell is not None :
156+ row_delivery_date = _parse_delivery_date (date_cell )
157+ if delivery_date is not None and row_delivery_date != delivery_date :
158+ raise ParserException (
159+ PARSER ,
160+ (
161+ "DAM table contains multiple delivery dates: "
162+ f"{ delivery_date .isoformat ()} and { row_delivery_date .isoformat ()} "
163+ ),
164+ )
165+ delivery_date = row_delivery_date
166+
167+ time_block_index = next (
168+ (
169+ i
170+ for i , cell in enumerate (cells )
171+ if _TIME_BLOCK_RE .fullmatch (cell .strip ())
172+ ),
173+ None ,
174+ )
175+ if time_block_index is None :
122176 # Skip header leftovers / Max / Average / Sum summary rows.
123177 continue
124- if not mcp_raw or mcp_raw in {"-" , "NA" , "N/A" }:
178+ if delivery_date is None :
179+ raise ParserException (
180+ PARSER ,
181+ "DAM time-block row appeared before a delivery date" ,
182+ )
183+
184+ mcp_index = time_block_index + mcp_offset
185+ if mcp_index < 0 or mcp_index >= len (cells ):
186+ raise ParserException (
187+ PARSER ,
188+ f"DAM row for { cells [time_block_index ]!r} is missing its MCP cell" ,
189+ )
190+
191+ time_block = cells [time_block_index ].strip ()
192+ mcp_raw = cells [mcp_index ].strip ()
193+ if mcp_raw .upper () in _MISSING_PRICE_MARKERS :
125194 logger .warning ("Skipping DAM block %s with empty MCP" , time_block )
126195 continue
127196 try :
@@ -134,6 +203,8 @@ def _parse_dam_html(
134203 start , end = _block_bounds (delivery_date , time_block )
135204 rows .append ((start , end , price ))
136205
206+ if delivery_date is None :
207+ raise ParserException (PARSER , "No delivery date found in DAM heading or rows" )
137208 if not rows :
138209 raise ParserException (PARSER , "No DAM time-block rows found in HTML" )
139210
0 commit comments