CoolFace
Apppublic

Figure31/pattern-finder

sourceHugging Faceupdated 2y agoView on Hugging Face
0likes
btc_pattern_finder.py680 linesDownload Raw Back to root
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        }