# source: https://raw.githubusercontent.com/remiotore/freqtrade/44beaeb6a420cd8e9f2e4ea93e11d6cfa192ee03/strategies/Markowitz.py
import logging
from datetime import datetime

import numpy as np
from pandas import DataFrame

from freqtrade.strategy import IntParameter, IStrategy, DecimalParameter


logger = logging.getLogger(__name__)

class Github_remiotore_freqtrade__Markowitz__20260111_210550(IStrategy):
    """
    Strategy implementing Modern Portfolio Theory (Markowitz), Long only.
    """

    INTERFACE_VERSION = 3

    position_adjustment_enable = True

    minimal_roi = {
        "0": 1  # No ROI required as we're using portfolio optimization
    }

    stoploss = -0.5  # No stoploss as we're using portfolio optimization

    trailing_stop = False

    can_short = False

    timeframe = "1d"

    # Timeframe for covariance estimation
    cov_timeframe = "1h"

    # Lookback period for calculating returns and covariance (in hours)
    lookback_period = IntParameter(24, 168, default=72, space="buy")  # 1-7 days in hours

    # Risk-free rate (annualized)
    risk_free_rate = 0.02

    # Risk aversion parameter
    risk_aversion = IntParameter(1, 5, default=1, space="buy")
    gamma = DecimalParameter(0.1, 1.0, default=0.8, space="buy")  # Risk aversion coefficient

    # Minimum position adjustment threshold (in percentage)
    min_adjustment_threshold = 0.05  # 5% change in weight triggers rebalancing

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Calculate daily returns for each pair
        """
        # Calculate returns
        dataframe["returns"] = dataframe["close"].pct_change()
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Always return True as we want to be in the market at all times
        """
        dataframe["enter_long"] = 1
        # dataframe["enter_short"] = 1
        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Always return False as we don't want to exit based on signals
        """
        dataframe["exit_long"] = 0
        dataframe["exit_short"] = 0
        return dataframe

    def _calculate_portfolio_weights(self, pair: str) -> tuple[float, bool]:
        """
        Calculate portfolio weights using 1h data for better covariance estimation
        Returns: (weight, success)
        """
        # Get all available pairs
        pairs = self.dp.current_whitelist()

        # Get historical data for all pairs using 1h timeframe
        lookback = self.lookback_period.value
        returns_data = {}

        for p in pairs:
            df = self.dp.get_pair_dataframe(p, self.cov_timeframe)
            if len(df) >= lookback:
                # Calculate hourly returns
                returns_data[p] = df["close"].pct_change().tail(lookback).values

        if not returns_data:
            return 0.0, False

        # Convert returns to numpy array
        returns_matrix = np.array([returns_data[p] for p in pairs])

        # Calculate expected returns and covariance matrix
        expected_returns = np.mean(returns_matrix, axis=1)
        cov_matrix = np.cov(returns_matrix)

        try:
            # Calculate optimal weights using inverse covariance matrix
            inv_cov = np.linalg.inv(cov_matrix)
            ones = np.ones(len(pairs))

            # Calculate optimal weights
            numerator = inv_cov @ (
                expected_returns - self.risk_free_rate / (252 * 24) + self.gamma.value * ones
            )
            denominator = ones @ numerator
            weights = numerator / denominator

            # Apply risk aversion
            weights = weights / self.risk_aversion.value

            # Get the weight for the current pair
            pair_idx = pairs.index(pair)
            weight = weights[pair_idx]

            return weight, True

        except np.linalg.LinAlgError:
            return 0.0, False

    def custom_stake_amount(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_stake: float,
        min_stake: float | None,
        max_stake: float,
        leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs,
    ) -> float:
        """
        Calculate initial position size based on Markowitz portfolio weights
        """
        weight, success = self._calculate_portfolio_weights(pair)
        if success:
            logger.info(f"Weight for {pair}: {weight}")

        if not success:
            return 0

        # Calculate position size
        total_stake = self.wallets.get_total_stake_amount()
        position_size = abs(weight) * total_stake

        # Ensure position size is within limits
        position_size = max(min_stake, min(position_size, max_stake))

        # If weight is negative, we're shorting
        if weight < 0:
            position_size = -position_size

        return position_size

    def adjust_trade_position(
        self,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        min_stake: float | None,
        max_stake: float,
        current_entry_rate: float,
        current_exit_rate: float,
        current_entry_profit: float,
        current_exit_profit: float,
        **kwargs,
    ) -> float | None:
        """
        Adjust position size based on updated portfolio weights
        Returns the new stake amount if adjustment is needed, None otherwise
        """
        pair = trade.pair
        weight, success = self._calculate_portfolio_weights(pair)

        if success:
            logger.info(f"Weight for {pair}: {weight}")

        if not success:
            return None

        # Calculate target position size
        total_stake = self.wallets.get_total_stake_amount()
        target_position = abs(weight) * total_stake
        target_position = max(min_stake, min(target_position, max_stake))

        if weight < 0:
            target_position = -target_position

        # Get current position size
        current_position = trade.amount * current_rate

        # Calculate position difference
        position_diff = target_position - current_position
        position_diff_pct = abs(position_diff / current_position) if current_position != 0 else 1.0

        # Only adjust if the difference is significant
        if position_diff_pct >= self.min_adjustment_threshold:
            # logger.info(
            #     f"Adjusting position for {pair}: "
            #     f"Current: {current_position:.2f}, Target: {target_position:.2f}, "
            #     f"Diff: {position_diff:.2f} ({position_diff_pct:.1%})"
            # )
            if position_diff > 0:
                try:
                    return position_diff
                except Exception as e:
                    logger.error(f"Error adjusting position for {pair}: {e}")
                    return None
            else:
                return position_diff
        else:
            # logger.debug(
            #     f"No adjustment needed for {pair}: "
            #     f"Current: {current_position:.2f}, Target: {target_position:.2f}, "
            #     f"Diff: {position_diff:.2f} ({position_diff_pct:.1%})"
            # )
            return None

    def leverage(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_leverage: float,
        max_leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs,
    ) -> float:
        """
        Set leverage to 1 as we're handling position sizing in custom_stake_amount
        """
        return 1.0
