"""
MSA (Market Structure Analysis) - Feature Engineering
======================================================
Feature engineering for MSA-based ML models.

Converts structure analysis into numerical features:
- Structure features (swing points, BOS, CHoCH)
- Distance to Points of Interest (POI)
- POI type classification
- Liquidity level features
- Structure reaction labels

Author: ML-Specialist (NEXUS v4.0)
Created: 2026-01-25
Version: 1.0.0
"""

import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass
from loguru import logger

from .structure_detector import (
    StructureDetector,
    SwingPoint,
    StructureEvent,
    FairValueGap,
    OrderBlock,
    LiquidityLevel,
    PremiumDiscountZone,
    StructureType,
    SwingType,
    POIType
)


@dataclass
class PointOfInterest:
    """Generic Point of Interest for distance calculations"""
    poi_type: POIType
    price_low: float
    price_high: float
    index: int
    strength: float = 0.5
    active: bool = True

    @property
    def midpoint(self) -> float:
        return (self.price_low + self.price_high) / 2


class MSAFeatureEngineer:
    """
    Feature Engineering for Market Structure Analysis.

    Generates numerical features from structure analysis results
    suitable for ML model training.

    Features generated:
    - Swing point features (distance, type, count)
    - BOS/CHoCH features (recent events, direction)
    - FVG features (count, distance, filled ratio)
    - Order Block features (count, distance, validity)
    - Premium/Discount zone features
    - Liquidity level features
    - POI distance features
    - Trend strength features
    """

    def __init__(
        self,
        swing_order: int = 5,
        lookback_periods: int = 50,
        max_pois: int = 10
    ):
        """
        Initialize MSA Feature Engineer.

        Args:
            swing_order: Order for swing point detection
            lookback_periods: Periods to look back for feature calculation
            max_pois: Maximum number of POIs to track
        """
        self.swing_order = swing_order
        self.lookback_periods = lookback_periods
        self.max_pois = max_pois
        self.detector = StructureDetector(swing_order=swing_order)

        logger.info(
            f"MSAFeatureEngineer initialized: order={swing_order}, "
            f"lookback={lookback_periods}"
        )

    def compute_structure_features(
        self,
        df: pd.DataFrame
    ) -> pd.DataFrame:
        """
        Compute all structure-based features.

        Args:
            df: OHLCV DataFrame

        Returns:
            DataFrame with structure features
        """
        features = pd.DataFrame(index=df.index)

        # Get structure analysis
        swing_highs, swing_lows = self.detector.detect_swing_points(df)
        classified_highs, classified_lows = self.detector.detect_higher_highs_lower_lows(
            swing_highs, swing_lows
        )
        bos_events = self.detector.detect_break_of_structure(df, classified_highs, classified_lows)
        choch_events = self.detector.detect_change_of_character(df, classified_highs, classified_lows)
        fvgs = self.detector.detect_fair_value_gaps(df)
        order_blocks = self.detector.detect_order_blocks(df, bos_events)
        zones = self.detector.compute_premium_discount_zone(df, classified_highs, classified_lows)
        liquidity_levels = self.detector.detect_liquidity_levels(classified_highs, classified_lows, df)

        # Compute feature categories
        features = self._add_swing_features(df, features, classified_highs, classified_lows)
        features = self._add_bos_choch_features(df, features, bos_events, choch_events)
        features = self._add_fvg_features(df, features, fvgs)
        features = self._add_order_block_features(df, features, order_blocks)
        features = self._add_zone_features(df, features, zones)
        features = self._add_liquidity_features(df, features, liquidity_levels)
        features = self._add_trend_features(df, features, classified_highs, classified_lows)

        # Collect POIs for distance features
        pois = self._collect_pois(fvgs, order_blocks, classified_highs, classified_lows, liquidity_levels)
        features = self._add_poi_distance_features(df, features, pois)

        # Fill NaN with 0
        features = features.fillna(0)

        logger.debug(f"Generated {len(features.columns)} structure features")
        return features

    def _add_swing_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        swing_highs: List[SwingPoint],
        swing_lows: List[SwingPoint]
    ) -> pd.DataFrame:
        """Add swing point features"""
        close = df['close'].values

        # Initialize arrays
        dist_to_swing_high = np.zeros(len(df))
        dist_to_swing_low = np.zeros(len(df))
        hh_count = np.zeros(len(df))
        hl_count = np.zeros(len(df))
        lh_count = np.zeros(len(df))
        ll_count = np.zeros(len(df))
        last_swing_type = np.zeros(len(df))  # 1=high, -1=low

        # Create index mappings
        high_indices = {sh.index: sh for sh in swing_highs}
        low_indices = {sl.index: sl for sl in swing_lows}

        # Calculate features for each bar
        last_high = None
        last_low = None

        for i in range(len(df)):
            # Update last swing if at this index
            if i in high_indices:
                last_high = high_indices[i]
                last_swing_type[i] = 1
            if i in low_indices:
                last_low = low_indices[i]
                last_swing_type[i] = -1

            # Distance to last swing high
            if last_high is not None:
                dist_to_swing_high[i] = (close[i] - last_high.price) / close[i]

            # Distance to last swing low
            if last_low is not None:
                dist_to_swing_low[i] = (close[i] - last_low.price) / close[i]

            # Count swing types in lookback window
            lookback_start = max(0, i - self.lookback_periods)
            for sh in swing_highs:
                if lookback_start <= sh.index <= i:
                    if sh.swing_type == SwingType.HIGHER_HIGH:
                        hh_count[i] += 1
                    else:
                        lh_count[i] += 1

            for sl in swing_lows:
                if lookback_start <= sl.index <= i:
                    if sl.swing_type == SwingType.HIGHER_LOW:
                        hl_count[i] += 1
                    else:
                        ll_count[i] += 1

        features['dist_to_swing_high'] = dist_to_swing_high
        features['dist_to_swing_low'] = dist_to_swing_low
        features['hh_count'] = hh_count
        features['hl_count'] = hl_count
        features['lh_count'] = lh_count
        features['ll_count'] = ll_count
        features['last_swing_type'] = last_swing_type

        # Swing structure score (positive = bullish, negative = bearish)
        features['swing_structure_score'] = (hh_count + hl_count - lh_count - ll_count) / 10

        return features

    def _add_bos_choch_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        bos_events: List[StructureEvent],
        choch_events: List[StructureEvent]
    ) -> pd.DataFrame:
        """Add BOS and CHoCH features"""
        # Initialize arrays
        bos_bullish_count = np.zeros(len(df))
        bos_bearish_count = np.zeros(len(df))
        choch_bullish_count = np.zeros(len(df))
        choch_bearish_count = np.zeros(len(df))
        bars_since_bos = np.full(len(df), 999)
        bars_since_choch = np.full(len(df), 999)
        last_bos_direction = np.zeros(len(df))  # 1=bullish, -1=bearish
        last_choch_direction = np.zeros(len(df))

        # Track events
        last_bos_idx = -999
        last_choch_idx = -999
        last_bos_dir = 0
        last_choch_dir = 0

        for i in range(len(df)):
            # Update from BOS events
            for bos in bos_events:
                if bos.index == i:
                    last_bos_idx = i
                    if bos.event_type == StructureType.BOS_BULLISH:
                        last_bos_dir = 1
                    else:
                        last_bos_dir = -1

                # Count in lookback
                if i - self.lookback_periods <= bos.index <= i:
                    if bos.event_type == StructureType.BOS_BULLISH:
                        bos_bullish_count[i] += 1
                    else:
                        bos_bearish_count[i] += 1

            # Update from CHoCH events
            for choch in choch_events:
                if choch.index == i:
                    last_choch_idx = i
                    if choch.event_type == StructureType.CHOCH_BULLISH:
                        last_choch_dir = 1
                    else:
                        last_choch_dir = -1

                if i - self.lookback_periods <= choch.index <= i:
                    if choch.event_type == StructureType.CHOCH_BULLISH:
                        choch_bullish_count[i] += 1
                    else:
                        choch_bearish_count[i] += 1

            bars_since_bos[i] = min(999, i - last_bos_idx) if last_bos_idx >= 0 else 999
            bars_since_choch[i] = min(999, i - last_choch_idx) if last_choch_idx >= 0 else 999
            last_bos_direction[i] = last_bos_dir
            last_choch_direction[i] = last_choch_dir

        features['bos_bullish_count'] = bos_bullish_count
        features['bos_bearish_count'] = bos_bearish_count
        features['choch_bullish_count'] = choch_bullish_count
        features['choch_bearish_count'] = choch_bearish_count
        features['bars_since_bos'] = bars_since_bos / 100  # Normalize
        features['bars_since_choch'] = bars_since_choch / 100
        features['last_bos_direction'] = last_bos_direction
        features['last_choch_direction'] = last_choch_direction

        # Net structure bias
        features['bos_net_bias'] = bos_bullish_count - bos_bearish_count
        features['has_recent_choch'] = (bars_since_choch < 20).astype(float)

        return features

    def _add_fvg_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        fvgs: List[FairValueGap]
    ) -> pd.DataFrame:
        """Add Fair Value Gap features"""
        close = df['close'].values

        # Initialize arrays
        bullish_fvg_count = np.zeros(len(df))
        bearish_fvg_count = np.zeros(len(df))
        unfilled_fvg_count = np.zeros(len(df))
        dist_to_nearest_fvg = np.full(len(df), 999.0)
        nearest_fvg_type = np.zeros(len(df))  # 1=bullish, -1=bearish
        in_fvg = np.zeros(len(df))
        fvg_fill_rate = np.zeros(len(df))

        for i in range(len(df)):
            bullish_count = 0
            bearish_count = 0
            unfilled_count = 0
            filled_count = 0
            min_dist = 999.0
            min_dist_type = 0

            for fvg in fvgs:
                if fvg.index <= i:
                    if fvg.gap_type == 'bullish':
                        bullish_count += 1
                    else:
                        bearish_count += 1

                    if not fvg.filled:
                        unfilled_count += 1
                        # Distance to FVG
                        dist_to_fvg = min(
                            abs(close[i] - fvg.high),
                            abs(close[i] - fvg.low)
                        ) / close[i]

                        if dist_to_fvg < min_dist:
                            min_dist = dist_to_fvg
                            min_dist_type = 1 if fvg.gap_type == 'bullish' else -1

                        # Check if price is inside FVG
                        if fvg.low <= close[i] <= fvg.high:
                            in_fvg[i] = 1 if fvg.gap_type == 'bullish' else -1
                    else:
                        filled_count += 1

            bullish_fvg_count[i] = bullish_count
            bearish_fvg_count[i] = bearish_count
            unfilled_fvg_count[i] = unfilled_count
            dist_to_nearest_fvg[i] = min_dist
            nearest_fvg_type[i] = min_dist_type

            if bullish_count + bearish_count > 0:
                fvg_fill_rate[i] = filled_count / (bullish_count + bearish_count)

        features['bullish_fvg_count'] = bullish_fvg_count
        features['bearish_fvg_count'] = bearish_fvg_count
        features['unfilled_fvg_count'] = unfilled_fvg_count
        features['dist_to_nearest_fvg'] = np.clip(dist_to_nearest_fvg, 0, 1)
        features['nearest_fvg_type'] = nearest_fvg_type
        features['in_fvg'] = in_fvg
        features['fvg_fill_rate'] = fvg_fill_rate
        features['fvg_net_bias'] = bullish_fvg_count - bearish_fvg_count

        return features

    def _add_order_block_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        order_blocks: List[OrderBlock]
    ) -> pd.DataFrame:
        """Add Order Block features"""
        close = df['close'].values

        # Initialize arrays
        bullish_ob_count = np.zeros(len(df))
        bearish_ob_count = np.zeros(len(df))
        valid_ob_count = np.zeros(len(df))
        dist_to_nearest_ob = np.full(len(df), 999.0)
        nearest_ob_type = np.zeros(len(df))
        in_ob = np.zeros(len(df))
        ob_strength_avg = np.zeros(len(df))

        for i in range(len(df)):
            bullish_count = 0
            bearish_count = 0
            valid_count = 0
            min_dist = 999.0
            min_dist_type = 0
            strength_sum = 0.0
            ob_count = 0

            for ob in order_blocks:
                if ob.index <= i:
                    if ob.ob_type == 'bullish':
                        bullish_count += 1
                    else:
                        bearish_count += 1

                    if ob.valid:
                        valid_count += 1
                        ob_count += 1
                        strength_sum += ob.strength

                        # Distance to OB
                        dist_to_ob = min(
                            abs(close[i] - ob.high),
                            abs(close[i] - ob.low)
                        ) / close[i]

                        if dist_to_ob < min_dist:
                            min_dist = dist_to_ob
                            min_dist_type = 1 if ob.ob_type == 'bullish' else -1

                        # Check if price is inside OB
                        if ob.low <= close[i] <= ob.high:
                            in_ob[i] = 1 if ob.ob_type == 'bullish' else -1

            bullish_ob_count[i] = bullish_count
            bearish_ob_count[i] = bearish_count
            valid_ob_count[i] = valid_count
            dist_to_nearest_ob[i] = min_dist
            nearest_ob_type[i] = min_dist_type

            if ob_count > 0:
                ob_strength_avg[i] = strength_sum / ob_count

        features['bullish_ob_count'] = bullish_ob_count
        features['bearish_ob_count'] = bearish_ob_count
        features['valid_ob_count'] = valid_ob_count
        features['dist_to_nearest_ob'] = np.clip(dist_to_nearest_ob, 0, 1)
        features['nearest_ob_type'] = nearest_ob_type
        features['in_ob'] = in_ob
        features['ob_strength_avg'] = ob_strength_avg
        features['ob_net_bias'] = bullish_ob_count - bearish_ob_count

        return features

    def _add_zone_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        zones: PremiumDiscountZone
    ) -> pd.DataFrame:
        """Add Premium/Discount zone features"""
        close = df['close'].values

        # Price position relative to zones
        range_size = zones.premium_high - zones.discount_low
        if range_size > 0:
            position_in_range = (close - zones.discount_low) / range_size
        else:
            position_in_range = np.full(len(df), 0.5)

        features['position_in_range'] = np.clip(position_in_range, 0, 1)
        features['dist_to_equilibrium'] = (close - zones.equilibrium) / close
        features['in_premium'] = (close >= zones.premium_low).astype(float)
        features['in_discount'] = (close <= zones.discount_high).astype(float)
        features['dist_to_premium'] = np.clip((zones.premium_low - close) / close, -1, 1)
        features['dist_to_discount'] = np.clip((close - zones.discount_high) / close, -1, 1)

        return features

    def _add_liquidity_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        liquidity_levels: List[LiquidityLevel]
    ) -> pd.DataFrame:
        """Add liquidity level features"""
        close = df['close'].values

        # Initialize arrays
        equal_highs_count = np.zeros(len(df))
        equal_lows_count = np.zeros(len(df))
        dist_to_nearest_liquidity = np.full(len(df), 999.0)
        liquidity_above = np.zeros(len(df))
        liquidity_below = np.zeros(len(df))

        for i in range(len(df)):
            min_dist = 999.0
            liq_above = 0
            liq_below = 0

            for ll in liquidity_levels:
                if not ll.swept:
                    # Distance to liquidity
                    dist = abs(close[i] - ll.price) / close[i]
                    if dist < min_dist:
                        min_dist = dist

                    if ll.price > close[i]:
                        liq_above += ll.strength
                    else:
                        liq_below += ll.strength

                if ll.level_type == 'equal_highs':
                    equal_highs_count[i] += 1
                else:
                    equal_lows_count[i] += 1

            dist_to_nearest_liquidity[i] = min_dist
            liquidity_above[i] = liq_above
            liquidity_below[i] = liq_below

        features['equal_highs_count'] = equal_highs_count
        features['equal_lows_count'] = equal_lows_count
        features['dist_to_nearest_liquidity'] = np.clip(dist_to_nearest_liquidity, 0, 1)
        features['liquidity_above'] = liquidity_above
        features['liquidity_below'] = liquidity_below
        features['liquidity_imbalance'] = (liquidity_above - liquidity_below) / (liquidity_above + liquidity_below + 1)

        return features

    def _add_trend_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        swing_highs: List[SwingPoint],
        swing_lows: List[SwingPoint]
    ) -> pd.DataFrame:
        """Add trend strength features based on structure"""
        # Trend strength based on swing sequence
        hh_hl_score = np.zeros(len(df))
        lh_ll_score = np.zeros(len(df))

        for i in range(len(df)):
            # Count recent swing patterns
            recent_highs = [sh for sh in swing_highs if i - self.lookback_periods <= sh.index <= i]
            recent_lows = [sl for sl in swing_lows if i - self.lookback_periods <= sl.index <= i]

            # Bullish structure: HH + HL
            hh = sum(1 for sh in recent_highs if sh.swing_type == SwingType.HIGHER_HIGH)
            hl = sum(1 for sl in recent_lows if sl.swing_type == SwingType.HIGHER_LOW)
            hh_hl_score[i] = (hh + hl) / max(1, len(recent_highs) + len(recent_lows))

            # Bearish structure: LH + LL
            lh = sum(1 for sh in recent_highs if sh.swing_type == SwingType.LOWER_HIGH)
            ll = sum(1 for sl in recent_lows if sl.swing_type == SwingType.LOWER_LOW)
            lh_ll_score[i] = (lh + ll) / max(1, len(recent_highs) + len(recent_lows))

        features['bullish_structure_score'] = hh_hl_score
        features['bearish_structure_score'] = lh_ll_score
        features['structure_trend_bias'] = hh_hl_score - lh_ll_score

        return features

    def _collect_pois(
        self,
        fvgs: List[FairValueGap],
        order_blocks: List[OrderBlock],
        swing_highs: List[SwingPoint],
        swing_lows: List[SwingPoint],
        liquidity_levels: List[LiquidityLevel]
    ) -> List[PointOfInterest]:
        """Collect all Points of Interest for distance calculations"""
        pois = []

        # Add unfilled FVGs
        for fvg in fvgs:
            if not fvg.filled:
                pois.append(PointOfInterest(
                    poi_type=POIType.FAIR_VALUE_GAP,
                    price_low=fvg.low,
                    price_high=fvg.high,
                    index=fvg.index,
                    strength=1.0 - fvg.fill_pct,
                    active=True
                ))

        # Add valid Order Blocks
        for ob in order_blocks:
            if ob.valid:
                pois.append(PointOfInterest(
                    poi_type=POIType.ORDER_BLOCK,
                    price_low=ob.low,
                    price_high=ob.high,
                    index=ob.index,
                    strength=ob.strength,
                    active=not ob.broken
                ))

        # Add recent swing highs
        for sh in swing_highs[-self.max_pois:]:
            pois.append(PointOfInterest(
                poi_type=POIType.SWING_HIGH,
                price_low=sh.price * 0.999,
                price_high=sh.price * 1.001,
                index=sh.index,
                strength=0.5,
                active=True
            ))

        # Add recent swing lows
        for sl in swing_lows[-self.max_pois:]:
            pois.append(PointOfInterest(
                poi_type=POIType.SWING_LOW,
                price_low=sl.price * 0.999,
                price_high=sl.price * 1.001,
                index=sl.index,
                strength=0.5,
                active=True
            ))

        # Add liquidity levels
        for ll in liquidity_levels:
            if not ll.swept:
                pois.append(PointOfInterest(
                    poi_type=POIType.LIQUIDITY_LEVEL,
                    price_low=ll.price * 0.999,
                    price_high=ll.price * 1.001,
                    index=min(ll.indices) if ll.indices else 0,
                    strength=float(ll.strength) / 5,
                    active=True
                ))

        return pois

    def _add_poi_distance_features(
        self,
        df: pd.DataFrame,
        features: pd.DataFrame,
        pois: List[PointOfInterest]
    ) -> pd.DataFrame:
        """Add distance to POI features"""
        close = df['close'].values

        dist_to_nearest_poi = np.full(len(df), 999.0)
        nearest_poi_type = np.zeros(len(df))  # 0=none, 1=OB, 2=FVG, 3=swing, 4=liquidity
        in_poi = np.zeros(len(df))
        poi_count_nearby = np.zeros(len(df))

        poi_type_map = {
            POIType.ORDER_BLOCK: 1,
            POIType.FAIR_VALUE_GAP: 2,
            POIType.SWING_HIGH: 3,
            POIType.SWING_LOW: 3,
            POIType.LIQUIDITY_LEVEL: 4,
            POIType.NONE: 0
        }

        for i in range(len(df)):
            min_dist = 999.0
            min_type = 0
            nearby_count = 0

            for poi in pois:
                if poi.active and poi.index <= i:
                    # Distance to POI
                    dist = min(
                        abs(close[i] - poi.price_high),
                        abs(close[i] - poi.price_low)
                    ) / close[i]

                    if dist < min_dist:
                        min_dist = dist
                        min_type = poi_type_map.get(poi.poi_type, 0)

                    # Count POIs within 1% range
                    if dist < 0.01:
                        nearby_count += 1

                    # Check if inside POI
                    if poi.price_low <= close[i] <= poi.price_high:
                        in_poi[i] = poi_type_map.get(poi.poi_type, 0)

            dist_to_nearest_poi[i] = min_dist
            nearest_poi_type[i] = min_type
            poi_count_nearby[i] = nearby_count

        features['dist_to_nearest_poi'] = np.clip(dist_to_nearest_poi, 0, 1)
        features['nearest_poi_type'] = nearest_poi_type
        features['in_poi'] = in_poi
        features['poi_count_nearby'] = poi_count_nearby

        return features

    def compute_distance_to_poi(
        self,
        df: pd.DataFrame,
        pois: List[PointOfInterest]
    ) -> pd.Series:
        """
        Compute distance to nearest Point of Interest.

        Args:
            df: OHLCV DataFrame
            pois: List of Points of Interest

        Returns:
            Series with distance to nearest POI (as fraction of price)
        """
        close = df['close'].values
        distances = np.full(len(df), 999.0)

        for i in range(len(df)):
            min_dist = 999.0
            for poi in pois:
                if poi.active and poi.index <= i:
                    dist = min(
                        abs(close[i] - poi.price_high),
                        abs(close[i] - poi.price_low)
                    ) / close[i]
                    min_dist = min(min_dist, dist)
            distances[i] = min_dist

        return pd.Series(distances, index=df.index, name='dist_to_poi')

    def compute_poi_type(
        self,
        df: pd.DataFrame,
        pois: List[PointOfInterest]
    ) -> pd.Series:
        """
        Compute the type of nearest POI.

        Args:
            df: OHLCV DataFrame
            pois: List of Points of Interest

        Returns:
            Series with POI type (categorical)
        """
        close = df['close'].values
        poi_types = []

        for i in range(len(df)):
            min_dist = 999.0
            nearest_type = POIType.NONE

            for poi in pois:
                if poi.active and poi.index <= i:
                    dist = min(
                        abs(close[i] - poi.price_high),
                        abs(close[i] - poi.price_low)
                    ) / close[i]
                    if dist < min_dist:
                        min_dist = dist
                        nearest_type = poi.poi_type

            poi_types.append(nearest_type.value)

        return pd.Series(poi_types, index=df.index, name='poi_type')

    def label_structure_reactions(
        self,
        df: pd.DataFrame,
        pois: List[PointOfInterest],
        reaction_threshold_pct: float = 0.005,
        forward_bars: int = 10
    ) -> pd.Series:
        """
        Label whether price reacted (bounced) at POI.

        A reaction is defined as price touching a POI and then
        moving away by at least reaction_threshold_pct.

        Args:
            df: OHLCV DataFrame
            pois: List of Points of Interest
            reaction_threshold_pct: Minimum move to count as reaction
            forward_bars: Bars to look forward for reaction

        Returns:
            Series with labels: 1=bullish reaction, -1=bearish reaction, 0=no reaction
        """
        close = df['close'].values
        high = df['high'].values
        low = df['low'].values
        labels = np.zeros(len(df))

        for i in range(len(df) - forward_bars):
            for poi in pois:
                if poi.active and poi.index <= i:
                    # Check if price touched POI
                    touched = (low[i] <= poi.price_high and high[i] >= poi.price_low)

                    if touched:
                        # Check for reaction in next bars
                        future_high = high[i + 1:i + forward_bars + 1].max()
                        future_low = low[i + 1:i + forward_bars + 1].min()

                        # Bullish reaction (touched from below and bounced up)
                        if close[i] <= poi.midpoint:
                            reaction = (future_high - close[i]) / close[i]
                            if reaction >= reaction_threshold_pct:
                                labels[i] = 1
                                break

                        # Bearish reaction (touched from above and dropped)
                        if close[i] >= poi.midpoint:
                            reaction = (close[i] - future_low) / close[i]
                            if reaction >= reaction_threshold_pct:
                                labels[i] = -1
                                break

        return pd.Series(labels, index=df.index, name='poi_reaction')

    def compute_liquidity_levels(
        self,
        df: pd.DataFrame
    ) -> pd.DataFrame:
        """
        Compute liquidity level features.

        Args:
            df: OHLCV DataFrame

        Returns:
            DataFrame with liquidity level features
        """
        swing_highs, swing_lows = self.detector.detect_swing_points(df)
        classified_highs, classified_lows = self.detector.detect_higher_highs_lower_lows(
            swing_highs, swing_lows
        )
        liquidity_levels = self.detector.detect_liquidity_levels(
            classified_highs, classified_lows, df
        )

        features = pd.DataFrame(index=df.index)
        features = self._add_liquidity_features(df, features, liquidity_levels)

        return features


if __name__ == "__main__":
    # Test MSAFeatureEngineer
    import numpy as np

    np.random.seed(42)

    # Create sample OHLCV data
    n_samples = 500
    dates = pd.date_range('2024-01-01', periods=n_samples, freq='5min')

    price = 2000.0
    prices = [price]
    for i in range(1, n_samples):
        trend = 0.5 * np.sin(i / 50) + np.random.randn() * 0.3
        price = price + trend
        prices.append(price)

    prices = np.array(prices)

    df = pd.DataFrame({
        'open': prices + np.random.randn(n_samples) * 0.5,
        'high': prices + abs(np.random.randn(n_samples)) * 1.5,
        'low': prices - abs(np.random.randn(n_samples)) * 1.5,
        'close': prices + np.random.randn(n_samples) * 0.5,
        'volume': np.random.randint(100, 10000, n_samples)
    }, index=dates)

    df['high'] = df[['open', 'high', 'close']].max(axis=1)
    df['low'] = df[['open', 'low', 'close']].min(axis=1)

    # Create feature engineer and compute features
    engineer = MSAFeatureEngineer(swing_order=5)
    features = engineer.compute_structure_features(df)

    print("=== MSA Feature Engineering ===")
    print(f"Generated {len(features.columns)} features")
    print(f"\nFeature columns:\n{list(features.columns)}")
    print(f"\nSample features (last 5 rows):\n{features.tail()}")
    print(f"\nFeature statistics:\n{features.describe().T[['mean', 'std', 'min', 'max']]}")
