Figure31/pattern-finder
0
1import numpy as np2import pandas as pd3import stumpy4from datetime import datetime, timedelta5from typing import Dict, List, Tuple, Union, Literal, Optional6from feature_extraction import PriceFeatureExtractor7 8class BTCPatternFinder:9 """10 BTC Pattern Finder - Identifies similar patterns in Bitcoin price history11 Adapted from the original codebase's _find_similar_pattern function and12 CoinPriceChartFalshbackSearchUtility13 """14 15 # Adapted from interval_to_params in the original codebase16 # Modified allowed_day_difference to be very small - we only want to filter exact matches17 interval_to_params = {18 "1m": {"chart_length": 60, "fetch_days": 365, "allowed_day_difference": 0.01},19 "3m": {"chart_length": 60, "fetch_days": 365, "allowed_day_difference": 0.01},20 "5m": {"chart_length": 60, "fetch_days": 365, "allowed_day_difference": 0.01},21 "15m": {"chart_length": 48, "fetch_days": 365, "allowed_day_difference": 0.01},22 "30m": {"chart_length": 48, "fetch_days": 365, "allowed_day_difference": 0.01},23 "1h": {"chart_length": 24, "fetch_days": 365, "allowed_day_difference": 0.01},24 "4h": {"chart_length": 36, "fetch_days": 730, "allowed_day_difference": 0.01},25 "1d": {"chart_length": 30, "fetch_days": 2000, "allowed_day_difference": 0.01},26 "1w": {"chart_length": 114, "fetch_days": 4000, "allowed_day_difference": 0.01},27 }28 29 # Utility mapping to convert interval to points per day30 days_to_points_for_interval = {31 "1m": 24 * 60, # 1440 points per day32 "3m": 24 * 20, # 480 points per day33 "5m": 24 * 12, # 288 points per day34 "15m": 24 * 4, # 96 points per day35 "30m": 24 * 2, # 48 points per day36 "1h": 24, # 24 points per day37 "4h": 6, # 6 points per day38 "1d": 1, # 1 point per day39 "1w": 1/7, # ~0.14 points per day40 }41 42 def __init__(self, data_provider):43 self.data_provider = data_provider44 self.feature_extractor = None # Will be initialized on demand45 46 def _find_similar_pattern(47 self,48 source_prices: Union[list, np.ndarray],49 target_prices: Union[list, np.ndarray],50 max_matches: int = 10,51 ) -> List[Tuple[float, int]]:52 """53 Find similar patterns using STUMPY's Matrix Profile algorithm54 Directly adapted from the original codebase's _find_similar_pattern function55 56 Args:57 source_prices: The price pattern to search for58 target_prices: The historical price data to search in59 max_matches: Maximum number of matches to return60 61 Returns:62 List of tuples containing (distance, index) of matches63 """64 source = np.array(source_prices)65 target = np.array(target_prices)66 67 pattern_length = len(source) - 168 69 # Check for minimum pattern length - STUMPY requires at least 3 points for the query70 # (which means at least 4 candles for source_prices since we're using source[-pattern_length:])71 if pattern_length < 3:72 print(f"Error: Pattern length must be at least 4 candles (selected pattern has {pattern_length+1} candles)")73 return []74 75 if pattern_length >= len(target):76 raise ValueError("Pattern length must be shorter than both price series")77 78 query = source[-pattern_length:]79 80 try:81 # Using a more relaxed STUMPY configuration to get more matches82 stumpy.config.STUMPY_EXCL_ZONE_DENOM = np.inf83 84 # Request exactly the number of matches specified by the user's slider85 # But multiply by 10 internally to ensure we have enough before filtering86 internal_max = max_matches * 10 # Get 10x the requested amount to ensure enough after filtering87 matches = stumpy.match(88 query,89 target,90 max_distance=np.inf, # No maximum distance restriction91 max_matches=internal_max, # Get more than requested to ensure enough after filtering92 )93 94 # Restore default exclusion zone95 stumpy.config.STUMPY_EXCL_ZONE_DENOM = 496 97 # Convert to list of tuples if it's a numpy array98 # Also filter out any potential NaN distances99 if isinstance(matches, np.ndarray):100 return [(float(dist), int(idx)) for dist, idx in matches if not np.isnan(dist)]101 else:102 return matches103 104 except Exception as e:105 print(f"Error during pattern matching: {str(e)}")106 return []107 108 async def find_similar_patterns(109 self,110 symbol: str = "BTC", # Added symbol parameter with BTC default111 interval: str = "1d",112 chart_length: int = None,113 start_time: str = None,114 end_time: str = None,115 max_matches: int = 10,116 following_points: int = 20,117 search_start_time: str = None,118 search_end_time: str = None,119 source_idx_range: Tuple[int, int] = None, # Add parameter for source index range (DEPRECATED)120 source_pattern: List[Dict] = None, # NEW: Explicit source pattern data121 ) -> Dict:122 """123 Find historical patterns similar to a specified time range124 Adapted from the CoinPriceChartFalshbackSearchUtility.arun method125 126 Args:127 symbol: Asset symbol (e.g., "BTC", "ETH") to analyze128 interval: Timeframe for pattern matching (1m, 5m, 1h, 1d, etc.)129 chart_length: Number of candles to use for the pattern130 start_time: ISO format start time for pattern (if None, uses recent data)131 end_time: ISO format end time for pattern (if None, uses recent data)132 max_matches: Maximum number of matches to return133 following_points: Number of data points to include after each match134 search_start_time: Optional ISO format start time to limit the search range135 search_end_time: Optional ISO format end time to limit the search range136 137 Returns:138 Dictionary with pattern matches and metadata139 """140 params = self.interval_to_params[interval]141 142 if chart_length is None:143 chart_length = params["chart_length"]144 145 # NEW: Handle the case when source_pattern is directly provided146 if source_pattern is not None:147 # Convert the provided pattern directly to a DataFrame148 source_ohlcv = pd.DataFrame(source_pattern)149 150 # We still need start/end time for debug print151 if 'timestamp' in source_ohlcv.columns:152 # Get timestamps from the data153 first_ts = source_ohlcv['timestamp'].iloc[0]154 last_ts = source_ohlcv['timestamp'].iloc[-1]155 156 # Convert to datetime for display157 start_time = datetime.fromtimestamp(first_ts / 1000)158 end_time = datetime.fromtimestamp(last_ts / 1000)159 else:160 # Default values if timestamps not available161 start_time = datetime.now()162 end_time = datetime.now()163 else:164 # Original logic for timestamp-based pattern extraction165 current_time = datetime.now()166 167 if start_time is None and end_time is None:168 # Default to recent data169 end_time = current_time170 days_back = chart_length / self.days_to_points_for_interval[interval]171 start_time = end_time - timedelta(days=days_back)172 elif start_time is not None and end_time is None:173 # Use specified start and calculate end174 start_time = datetime.fromisoformat(start_time)175 days_forward = chart_length / self.days_to_points_for_interval[interval]176 end_time = start_time + timedelta(days=days_forward)177 elif start_time is None and end_time is not None:178 # Use specified end and calculate start179 end_time = datetime.fromisoformat(end_time)180 days_back = chart_length / self.days_to_points_for_interval[interval]181 start_time = end_time - timedelta(days=days_back)182 else:183 # Both specified184 start_time = datetime.fromisoformat(start_time)185 end_time = datetime.fromisoformat(end_time)186 187 # Ensure end_time doesn't exceed current time188 if end_time > current_time:189 end_time = current_time190 191 # Fetch source pattern (current or specified time range)192 source_ohlcv = await self.data_provider.get_historical_ohlcv(193 symbol=symbol, # Use the provided symbol194 interval=interval,195 start_time=start_time.isoformat(),196 end_time=end_time.isoformat()197 )198 199 # Source pattern validation200 if len(source_ohlcv) == 0:201 return {"error": "No data found for source pattern"}202 203 # Check if source pattern is too short (needs at least 4 candles)204 if len(source_ohlcv) < 4:205 return {"error": f"Pattern is too short. Please select at least 4 candles. (Selected: {len(source_ohlcv)} candles)"}206 207 # Fetch historical data for matching (extensive history)208 fetch_days = params["fetch_days"]209 210 # Use search range parameters if provided, otherwise use default range211 if search_start_time is not None:212 historical_start = datetime.fromisoformat(search_start_time)213 else:214 historical_start = current_time - timedelta(days=fetch_days)215 216 if search_end_time is not None:217 historical_end = datetime.fromisoformat(search_end_time)218 else:219 historical_end = current_time220 221 historical_ohlcv = await self.data_provider.get_historical_ohlcv(222 symbol=symbol, # Use the provided symbol223 interval=interval,224 start_time=historical_start.isoformat(),225 end_time=historical_end.isoformat()226 )227 228 if len(historical_ohlcv) == 0:229 return {"error": "No historical data found"}230 231 # Ensure we have at least 4 candles in the source pattern232 if len(source_ohlcv) < 4:233 return {"error": f"Pattern is too short. Please select at least 4 candles. (Selected: {len(source_ohlcv)} candles)"}234 235 # Find matches using the adapted function from the original codebase236 matches = self._find_similar_pattern(237 source_ohlcv["close"].values,238 historical_ohlcv["close"].values,239 max_matches=max_matches240 )241 242 if matches is None or len(matches) == 0:243 return {"error": "No matches found"}244 245 # Process matches246 match_results = []247 match_dates = []248 249 historical_dates = pd.to_datetime(historical_ohlcv["timestamp"], unit='ms')250 251 # Determine the source pattern's absolute timestamp range252 # This is the range we want to exclude from results253 source_timestamps = pd.to_datetime(source_ohlcv["timestamp"], unit='ms')254 source_start_timestamp = source_timestamps.min()255 source_end_timestamp = source_timestamps.max()256 257 # Use a minimal buffer - just enough to ensure exact timestamp matching works258 buffer = pd.Timedelta(seconds=1)259 260 # Apply buffer to exclusion zone261 exclude_start = source_start_timestamp - buffer262 exclude_end = source_end_timestamp + buffer263 264 # Process each match265 for match_dist, match_idx in matches:266 if match_idx >= len(historical_ohlcv):267 continue # Skip invalid indices268 269 # Get match timestamps - convert full pattern to datetime270 match_times = pd.to_datetime(historical_ohlcv.iloc[match_idx:match_idx+len(source_ohlcv)]["timestamp"], unit='ms')271 272 # Only use valid timestamps (in case we're at the end of data)273 if not match_times.empty:274 match_start_time = match_times.min()275 match_end_time = match_times.max()276 match_datetime = match_start_time # For compatibility277 278 # Check for overlap with the excluded zone279 # A match overlaps if ANY part of it is within the exclude zone280 no_overlap = (match_end_time < exclude_start) or (match_start_time > exclude_end)281 282 # Additional check to prevent similar shifted matches283 too_close_to_existing = False284 if no_overlap:285 # Check if this match is too close to any existing match286 for existing_date in match_dates:287 # Calculate time difference in seconds288 time_diff = abs((match_datetime - existing_date).total_seconds())289 290 # Convert interval to seconds for threshold291 if interval == "1d":292 threshold = 60 * 60 * 24 * 3 # 3 days293 elif interval == "1h":294 threshold = 60 * 60 * 5 # 5 hours295 elif interval == "4h":296 threshold = 60 * 60 * 10 # 10 hours297 elif interval == "30m":298 threshold = 60 * 30 * 5 # 2.5 hours299 elif interval == "15m":300 threshold = 60 * 15 * 7 # ~2 hours301 elif interval == "5m":302 threshold = 60 * 5 * 15 # ~1.25 hours303 else:304 threshold = 60 * 60 * 2 # 2 hours default305 306 if time_diff < threshold:307 too_close_to_existing = True308 break309 310 # More permissive filtering - just exclude exact pattern and duplicates311 if no_overlap and not too_close_to_existing and match_dist > 0.01: # Multiple filters312 match_dates.append(match_datetime)313 314 # Get data points after match for forward analysis315 # Make sure we get EXACTLY chart_length + following_points candles316 # chart_length is the pattern part, following_points is the future part317 target_length = chart_length + following_points318 available_length = min(len(historical_ohlcv) - match_idx, target_length)319 320 # If we have enough data, use the requested amount321 # Otherwise, use what's available but print a warning322 if available_length < target_length:323 print(f"Warning: Not enough data for match at {match_idx}. " 324 f"Requested {target_length} candles but only {available_length} available.")325 326 match_data = historical_ohlcv.iloc[match_idx:match_idx + available_length]327 328 # Calculate start and end times329 match_start = match_data.iloc[0]["timestamp"]330 match_end = match_data.iloc[-1]["timestamp"]331 332 match_results.append({333 "distance": float(match_dist),334 "start_time": datetime.fromtimestamp(match_start / 1000).isoformat(),335 "end_time": datetime.fromtimestamp(match_end / 1000).isoformat(),336 "timestamp": int(match_start),337 "pattern_data": match_data[["timestamp", "open", "high", "low", "close", "volume"]].to_dict("records"),338 "label": f"{symbol} from {match_datetime.strftime('%Y-%m-%d %H:%M')}"339 })340 341 # Sort by distance (similarity)342 sorted_results = sorted(match_results, key=lambda x: x["distance"])343 344 # Add debugging info to help understand filtering345 # Also apply a limit to the results based on the original max_matches request346 final_results = sorted_results[:max_matches] if len(sorted_results) > max_matches else sorted_results347 348 # Store filtered scores (after time proximity filtering) for distribution visualization349 # This will exclude near-duplicates and only show meaningful differences350 filtered_raw_scores = [match['distance'] for match in sorted_results]351 352 return {353 "type": "flashback_pattern",354 "symbol": symbol, # Include the symbol in the response355 "interval": interval,356 "start_time": start_time.isoformat(),357 "end_time": end_time.isoformat(),358 "following_points_number": following_points,359 "total_matches": len(final_results),360 "flashback_patterns": final_results,361 "filtered_matches_scores": filtered_raw_scores, # Include only time-filtered scores for histogram362 "debug_info": {363 "requested_matches": max_matches,364 "stumpy_matches_found": len(matches) if matches else 0,365 "unique_matches": len(sorted_results), # Matches after time-proximity filtering366 "final_matches": len(final_results)367 }368 }369 370 def _find_similar_pattern_feature_based(371 self,372 source_ohlcv: pd.DataFrame,373 historical_ohlcv: pd.DataFrame,374 window_size: Optional[int] = None,375 stride: int = 1,376 n_components: int = 2,377 max_matches: int = 10,378 ) -> Tuple[List[Tuple[float, int]], Dict]:379 """380 Find similar patterns using PCA feature extraction approach381 382 Args:383 source_ohlcv: DataFrame with the source pattern OHLCV data384 historical_ohlcv: DataFrame with historical OHLCV data to search in385 window_size: Size of the sliding window (default: len(source_ohlcv))386 stride: Steps between windows387 n_components: Number of components to use388 max_matches: Maximum number of matches to return389 390 Returns:391 Tuple of (matches, visualization_data)392 - matches: List of tuples with (distance, index)393 - visualization_data: Dict with data for visualizing feature space394 """395 if window_size is None:396 window_size = len(source_ohlcv)397 398 # Initialize the feature extractor if needed399 if self.feature_extractor is None or self.feature_extractor.window_size != window_size:400 self.feature_extractor = PriceFeatureExtractor(401 window_size=window_size,402 n_components=n_components403 )404 405 # Find similar patterns using our feature extractor406 matches, vis_data = self.feature_extractor.find_similar_patterns(407 source_ohlc=source_ohlcv,408 historical_ohlc=historical_ohlcv,409 window_size=window_size,410 stride=stride,411 top_n=max_matches * 10 # Get 10x requested to ensure enough after filtering412 )413 414 return matches, vis_data415 416 async def find_similar_patterns_feature_based(417 self,418 symbol: str = "BTC",419 interval: str = "1d",420 chart_length: int = None,421 start_time: str = None,422 end_time: str = None,423 max_matches: int = 10,424 following_points: int = 20,425 search_start_time: str = None,426 search_end_time: str = None,427 source_idx_range: Tuple[int, int] = None,428 source_pattern: List[Dict] = None,429 n_components: int = 2,430 ) -> Dict:431 """432 Find historical patterns similar to a specified time range using feature extraction433 434 Args:435 symbol: Asset symbol (e.g., "BTC", "ETH") to analyze436 interval: Timeframe for pattern matching (1m, 5m, 1h, 1d, etc.)437 chart_length: Number of candles to use for the pattern438 start_time: ISO format start time for pattern (if None, uses recent data)439 end_time: ISO format end time for pattern (if None, uses recent data)440 max_matches: Maximum number of matches to return441 following_points: Number of data points to include after each match442 search_start_time: Optional ISO format start time to limit the search range443 search_end_time: Optional ISO format end time to limit the search range444 source_idx_range: Optional tuple with start and end indices for source pattern445 source_pattern: Optional explicit source pattern data446 n_components: Number of components to use447 448 Returns:449 Dictionary with pattern matches and metadata450 """451 # Most logic is identical to find_similar_patterns, with only pattern matching algorithm changed452 params = self.interval_to_params[interval]453 454 if chart_length is None:455 chart_length = params["chart_length"]456 457 # Handle the case when source_pattern is directly provided458 if source_pattern is not None:459 # Convert the provided pattern directly to a DataFrame460 source_ohlcv = pd.DataFrame(source_pattern)461 462 # We still need start/end time for debug print463 if 'timestamp' in source_ohlcv.columns:464 # Get timestamps from the data465 first_ts = source_ohlcv['timestamp'].iloc[0]466 last_ts = source_ohlcv['timestamp'].iloc[-1]467 468 # Convert to datetime for display469 start_time = datetime.fromtimestamp(first_ts / 1000)470 end_time = datetime.fromtimestamp(last_ts / 1000)471 else:472 # Default values if timestamps not available473 start_time = datetime.now()474 end_time = datetime.now()475 else:476 # Original logic for timestamp-based pattern extraction477 current_time = datetime.now()478 479 if start_time is None and end_time is None:480 # Default to recent data481 end_time = current_time482 days_back = chart_length / self.days_to_points_for_interval[interval]483 start_time = end_time - timedelta(days=days_back)484 elif start_time is not None and end_time is None:485 # Use specified start and calculate end486 start_time = datetime.fromisoformat(start_time)487 days_forward = chart_length / self.days_to_points_for_interval[interval]488 end_time = start_time + timedelta(days=days_forward)489 elif start_time is None and end_time is not None:490 # Use specified end and calculate start491 end_time = datetime.fromisoformat(end_time)492 days_back = chart_length / self.days_to_points_for_interval[interval]493 start_time = end_time - timedelta(days=days_back)494 else:495 # Both specified496 start_time = datetime.fromisoformat(start_time)497 end_time = datetime.fromisoformat(end_time)498 499 # Ensure end_time doesn't exceed current time500 if end_time > current_time:501 end_time = current_time502 503 # Fetch source pattern (current or specified time range)504 source_ohlcv = await self.data_provider.get_historical_ohlcv(505 symbol=symbol,506 interval=interval,507 start_time=start_time.isoformat(),508 end_time=end_time.isoformat()509 )510 511 # Source pattern validation512 if len(source_ohlcv) == 0:513 return {"error": "No data found for source pattern"}514 515 # Check if source pattern is too short516 if len(source_ohlcv) < 4:517 return {"error": f"Pattern is too short. Please select at least 4 candles. (Selected: {len(source_ohlcv)} candles)"}518 519 # Fetch historical data for matching (extensive history)520 fetch_days = params["fetch_days"]521 522 # Use search range parameters if provided, otherwise use default range523 if search_start_time is not None:524 historical_start = datetime.fromisoformat(search_start_time)525 else:526 historical_start = datetime.now() - timedelta(days=fetch_days)527 528 if search_end_time is not None:529 historical_end = datetime.fromisoformat(search_end_time)530 else:531 historical_end = datetime.now()532 533 historical_ohlcv = await self.data_provider.get_historical_ohlcv(534 symbol=symbol,535 interval=interval,536 start_time=historical_start.isoformat(),537 end_time=historical_end.isoformat()538 )539 540 if len(historical_ohlcv) == 0:541 return {"error": "No historical data found"}542 543 # Performance optimization: Use stride of max(1, len(source_ohlcv)//5) for faster processing544 # This means we'll check every 5th candle, which dramatically speeds up processing545 # but still finds most of the important patterns546 stride = max(1, len(source_ohlcv)//5)547 print(f"Using stride of {stride} for feature extraction (sampling every {stride}th candle)")548 549 # Use feature-based pattern matching550 matches, vis_data = self._find_similar_pattern_feature_based(551 source_ohlcv=source_ohlcv,552 historical_ohlcv=historical_ohlcv,553 window_size=len(source_ohlcv),554 stride=stride, # Use stride for faster processing555 n_components=n_components,556 max_matches=max_matches557 )558 559 if matches is None or len(matches) == 0:560 return {"error": "No matches found"}561 562 # Process matches - same logic as find_similar_patterns563 match_results = []564 match_dates = []565 match_indices = [] # Store matched indices for visualization566 567 historical_dates = pd.to_datetime(historical_ohlcv["timestamp"], unit='ms')568 569 # Determine the source pattern's absolute timestamp range to exclude570 source_timestamps = pd.to_datetime(source_ohlcv["timestamp"], unit='ms')571 source_start_timestamp = source_timestamps.min()572 source_end_timestamp = source_timestamps.max()573 574 # Use a minimal buffer575 buffer = pd.Timedelta(seconds=1)576 exclude_start = source_start_timestamp - buffer577 exclude_end = source_end_timestamp + buffer578 579 # Process each match580 window_size = len(source_ohlcv)581 582 for match_dist, match_idx in matches:583 if match_idx >= len(historical_ohlcv):584 continue # Skip invalid indices585 586 # Get match timestamps587 match_times = pd.to_datetime(historical_ohlcv.iloc[match_idx:match_idx+window_size]["timestamp"], unit='ms')588 589 if not match_times.empty:590 match_start_time = match_times.min()591 match_end_time = match_times.max()592 match_datetime = match_start_time593 594 # Check for overlap with the excluded zone595 no_overlap = (match_end_time < exclude_start) or (match_start_time > exclude_end)596 597 # Check for proximity to existing matches598 too_close_to_existing = False599 if no_overlap:600 for existing_date in match_dates:601 time_diff = abs((match_datetime - existing_date).total_seconds())602 603 # Convert interval to seconds for threshold604 if interval == "1d":605 threshold = 60 * 60 * 24 * 3 # 3 days606 elif interval == "1h":607 threshold = 60 * 60 * 5 # 5 hours608 elif interval == "4h":609 threshold = 60 * 60 * 10 # 10 hours610 elif interval == "30m":611 threshold = 60 * 30 * 5 # 2.5 hours612 elif interval == "15m":613 threshold = 60 * 15 * 7 # ~2 hours614 elif interval == "5m":615 threshold = 60 * 5 * 15 # ~1.25 hours616 else:617 threshold = 60 * 60 * 2 # 2 hours default618 619 if time_diff < threshold:620 too_close_to_existing = True621 break622 623 # Apply filtering624 if no_overlap and not too_close_to_existing:625 match_dates.append(match_datetime)626 match_indices.append(match_idx)627 628 # Get data points after match for forward analysis629 target_length = window_size + following_points630 available_length = min(len(historical_ohlcv) - match_idx, target_length)631 632 if available_length < target_length:633 print(f"Warning: Not enough data for match at {match_idx}. " 634 f"Requested {target_length} candles but only {available_length} available.")635 636 match_data = historical_ohlcv.iloc[match_idx:match_idx + available_length]637 638 # Calculate start and end times639 match_start = match_data.iloc[0]["timestamp"]640 match_end = match_data.iloc[-1]["timestamp"]641 642 match_results.append({643 "distance": float(match_dist),644 "start_time": datetime.fromtimestamp(match_start / 1000).isoformat(),645 "end_time": datetime.fromtimestamp(match_end / 1000).isoformat(),646 "timestamp": int(match_start),647 "pattern_data": match_data[["timestamp", "open", "high", "low", "close", "volume"]].to_dict("records"),648 "label": f"{symbol} from {match_datetime.strftime('%Y-%m-%d %H:%M')}"649 })650 651 # Sort by distance652 sorted_results = sorted(match_results, key=lambda x: x["distance"])653 654 # Apply max_matches limit655 final_results = sorted_results[:max_matches] if len(sorted_results) > max_matches else sorted_results656 657 # Store filtered scores for distribution visualization658 filtered_raw_scores = [match['distance'] for match in sorted_results]659 660 return {661 "type": "feature_pattern",662 "symbol": symbol,663 "interval": interval,664 "start_time": start_time.isoformat(),665 "end_time": end_time.isoformat(),666 "following_points_number": following_points,667 "total_matches": len(final_results),668 "method": "pca",669 "n_components": n_components,670 "flashback_patterns": final_results,671 "filtered_matches_scores": filtered_raw_scores,672 "vis_data": vis_data,673 "match_indices": match_indices,674 "debug_info": {675 "requested_matches": max_matches,676 "feature_matches_found": len(matches) if matches else 0,677 "unique_matches": len(sorted_results),678 "final_matches": len(final_results)679 }680 }