# source: https://raw.githubusercontent.com/djienne/AVELLANEDA_MARKET_MAKING_FREQTRADE/b7dfc622e62944bc344c91443eff174cdaa7166f/user_data/strategies/avellaneda.py
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
# flake8: noqa: F401
# isort: skip_file
# --- Do not remove these libs ---
from warnings import simplefilter
import numpy as np  # noqa
import pandas as pd  # noqa
import sys
import threading
from run_Github_djienne_AVELLANEDA_MARKET_MAKING_FREQTRADE__avellaneda__20251201_224013_param_calculation import run_Github_djienne_AVELLANEDA_MARKET_MAKING_FREQTRADE__avellaneda__20251201_224013_param_calculation
from pandas import DataFrame
from functools import reduce
import json
import logging
from pathlib import Path
from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter,
                                IStrategy, IntParameter, stoploss_from_absolute, informative)
from freqtrade.exchange import timeframe_to_prev_date
from freqtrade.persistence import Trade, Order
from datetime import datetime, timezone
# --------------------------------
# Add your lib to import here
import math
from typing import Optional, Tuple
from dataclasses import dataclass
from pair_loader import get_active_pair, pair_to_ticker


logger = logging.getLogger(__name__)

# Setup dedicated logger for market making values
mm_logger = logging.getLogger('market_making_values')
mm_logger.setLevel(logging.INFO)
log_file_path = Path(__file__).parent / 'log_ave_mm.txt'
mm_handler = logging.FileHandler(log_file_path)
mm_formatter = logging.Formatter('%(asctime)s - %(message)s')
mm_handler.setFormatter(mm_formatter)
mm_logger.addHandler(mm_handler)
mm_logger.propagate = False

pd.set_option('display.max_rows', None)
pd.set_option('display.max_columns', None)
pd.set_option('display.width', None)
pd.set_option('display.max_colwidth', None)
pd.options.mode.chained_assignment = None


def calculate_optimal_spreads(mid_price, sigma, k_bid, k_ask, gamma, time_remaining, q_inventory_exposure, fee):
    """Compute reservation price and bid/ask quotes, logging the inputs/outputs."""
    sigma_abs = sigma * mid_price

    reservation_decay = gamma * sigma_abs**2.0 * time_remaining
    risk_term = 0.5 * reservation_decay

    half_spread_bid = risk_term + (1.0 / gamma) * np.log(1.0 + (gamma / k_bid))
    half_spread_ask = risk_term + (1.0 / gamma) * np.log(1.0 + (gamma / k_ask))

    r = mid_price - q_inventory_exposure * reservation_decay

    r_b = r - half_spread_bid - mid_price * fee
    r_a = r + half_spread_ask + mid_price * fee

    delta_a_rel = r_a - mid_price
    delta_b_rel = mid_price - r_b

    delta_a_percent = (delta_a_rel / mid_price) * 100.0
    delta_b_percent = (delta_b_rel / mid_price) * 100.0

    mm_logger.info("=" * 65)
    mm_logger.info("AVELLANEDA-STOIKOV MODEL PARAMETERS")
    mm_logger.info("=" * 65)
    mm_logger.info(f"Time Remaining Fraction: {time_remaining:>12.4f}")
    mm_logger.info(f"Inventory Exposure:      {q_inventory_exposure:>12.4f}")
    mm_logger.info(f"Sigma (Volatility):      {sigma:>12.6f}")
    mm_logger.info(f"K Bid:                   {k_bid:>12.6f}")
    mm_logger.info(f"K Ask:                   {k_ask:>12.6f}")
    mm_logger.info(f"Maker fee:               {fee:>12.6f}")
    mm_logger.info(f"Gamma:                   {gamma:>12.6f}")
    mm_logger.info(f"Mid-Price:               {mid_price:>12.4f}")
    mm_logger.info(f"Reservation Price:       {r:>12.4f}")
    mm_logger.info(f"Buy Spread (% of mid):   {delta_b_percent:>12.4f}%")
    mm_logger.info(f"Sell Spread (% of mid):  {delta_a_percent:>12.4f}%")
    mm_logger.info(f"Buy Limit Price:         {r_b:>12.4f}")
    mm_logger.info(f"Sell Limit Price:        {r_a:>12.4f}")
    mm_logger.info("=" * 65)

    return r_b, r_a


def _fmt_optional(value: float | None, digits: int = 6) -> str:
    return f"{value:.{digits}f}" if isinstance(value, (int, float)) else "n/a"

#---------------------------------------------------------- LOAD CONFIG ----------------------------------------------------------

def get_params_directory():
    """
    Get the directory containing parameter JSON files.
    Uses environment variable AVELLANEDA_PARAMS_DIR if set, otherwise searches for scripts/ directory.
    Works consistently whether running locally, in Docker, or in a container.

    Returns:
        Path: Directory where parameter files are located
    """
    import os

    # First check environment variable
    env_path = os.getenv('AVELLANEDA_PARAMS_DIR')
    if env_path:
        params_dir = Path(env_path).resolve()
        logger.info(f"Using params directory from AVELLANEDA_PARAMS_DIR: {params_dir}")
        return params_dir

    # Fall back to scripts directory relative to project root
    try:
        current_file = Path(__file__).resolve()
        current_dir = current_file.parent
    except NameError:  # e.g., interactive
        current_dir = Path(sys.argv[0]).resolve().parent if sys.argv and sys.argv[0] else Path.cwd()

    # Search for scripts directory
    search_paths = [
        current_dir / '../../scripts',  # From user_data/strategies/
        current_dir / '../scripts',
        current_dir / 'scripts',
        current_dir.parent.parent / 'scripts',
    ]

    for path in search_paths:
        resolved = path.resolve()
        if resolved.exists() and resolved.is_dir():
            logger.info(f"Using params directory: {resolved}")
            return resolved

    # If scripts/ not found, use current directory as fallback
    logger.warning(f"scripts/ directory not found, using current directory: {current_dir}")
    return current_dir


def find_upwards(filename: str, start: Path, max_up: int = 10) -> Path:
    p = start.resolve()
    for _ in range(max_up + 1):
        candidate = p / filename
        if candidate.exists():
            return candidate
        if p.parent == p:
            break
        p = p.parent
    raise FileNotFoundError(f"Could not find {filename} from {start}")


def log_parameters_summary(params_file: Path, params: dict) -> None:
    """Log a concise summary of the loaded parameters and spreads."""
    market_data = params.get('market_data', {})
    optimal_params = params.get('optimal_parameters', {})
    limit_orders = params.get('limit_orders', {})
    calculated_values = params.get('calculated_values', {})

    k_bid = market_data.get('k_bid', market_data.get('k'))
    k_ask = market_data.get('k_ask', market_data.get('k'))
    sigma = market_data.get('sigma')
    gamma = optimal_params.get('gamma')
    time_horizon_hours = optimal_params.get('time_horizon_hours')
    mid_price = market_data.get('mid_price') or calculated_values.get('reservation_price')

    delta_b = limit_orders.get('delta_b', calculated_values.get('half_spread_bid'))
    delta_a = limit_orders.get('delta_a', calculated_values.get('half_spread_ask'))
    delta_b_percent = limit_orders.get('delta_b_percent')
    delta_a_percent = limit_orders.get('delta_a_percent')
    bid_price = limit_orders.get('bid_price')
    ask_price = limit_orders.get('ask_price')

    logger.info(
        "Parameter summary | source=%s | gamma=%s | sigma=%s | k_bid=%s | k_ask=%s | horizon_h=%s | mid=%s",
        params_file,
        _fmt_optional(gamma),
        _fmt_optional(sigma),
        _fmt_optional(k_bid),
        _fmt_optional(k_ask),
        _fmt_optional(time_horizon_hours, 4),
        _fmt_optional(mid_price, 4),
    )
    logger.info(
        "Spread snapshot   | bid=%s (%s%%) | ask=%s (%s%%) | bid_px=%s | ask_px=%s",
        _fmt_optional(delta_b),
        _fmt_optional(delta_b_percent, 4),
        _fmt_optional(delta_a),
        _fmt_optional(delta_a_percent, 4),
        _fmt_optional(bid_price, 4),
        _fmt_optional(ask_price, 4),
    )


def load_configs(start_dir: Path | None = None, max_up: int = 10):
    """
    Load Avellaneda parameters from JSON file, searching in multiple locations.

    Args:
        start_dir: Starting directory for search (defaults to current file's directory)
        max_up: Maximum levels to search upwards

    Returns:
        dict: Loaded parameters from JSON file

    Raises:
        FileNotFoundError: If the parameters file cannot be found
    """
    params_dir = get_params_directory()

    try:
        active_pair = get_active_pair()
    except Exception:
        active_pair = None
    ticker = pair_to_ticker(active_pair) or "PAXG"
    param_file_name = f"Github_djienne_AVELLANEDA_MARKET_MAKING_FREQTRADE__avellaneda__20251201_224013_parameters_{ticker}.json"
    logger.info(f"Resolving parameters for pair '{active_pair or 'unknown'}' (ticker '{ticker}') using {params_dir}")

    params_file = params_dir / param_file_name

    if params_file.exists():
        try:
            params_MM = json.loads(params_file.read_text(encoding='utf-8'))
            logger.info(f"Successfully loaded parameters from: {params_file}")
            log_parameters_summary(params_file, params_MM)
            return params_MM
        except (json.JSONDecodeError, IOError) as e:
            logger.error(f"Error reading {params_file}: {e}")

    if start_dir is None:
        try:
            start_dir = Path(__file__).resolve().parent
        except NameError:  # e.g., interactive
            start_dir = Path(sys.argv[0]).resolve().parent if sys.argv and sys.argv[0] else Path.cwd()

    search_locations = [
        f"scripts/{param_file_name}",
        param_file_name,
        f"user_data/strategies/{param_file_name}",
        f"../scripts/{param_file_name}",
        f"../../scripts/{param_file_name}"
    ]

    for location in search_locations:
        try:
            params_file_found = find_upwards(location, start_dir, max_up)
            params_MM = json.loads(params_file_found.read_text(encoding='utf-8'))
            logger.info(f"Successfully loaded parameters from: {params_file_found}")
            log_parameters_summary(params_file_found, params_MM)
            return params_MM
        except FileNotFoundError:
            continue
        except (json.JSONDecodeError, IOError) as e:
            logger.error(f"Error reading {location}: {e}")
            continue

    raise FileNotFoundError(
        f"Could not find '{param_file_name}' in any of these locations:\n"
        f"  - Primary: {params_file}\n"
        f"  - " + "\n  - ".join(str(start_dir / loc) for loc in search_locations)
    )


class Github_djienne_AVELLANEDA_MARKET_MAKING_FREQTRADE__avellaneda__20251201_224013(IStrategy):

    # Strategy interface version - allow new iterations of the strategy interface.
    # Check the documentation or the Sample strategy to get the latest version.
    INTERFACE_VERSION = 3

    # Can this strategy go short?
    can_short: bool = False
    use_custom_stoploss: bool = False
    process_only_new_candles: bool = False
    position_adjustment_enable: bool = False
    max_entry_position_adjustment = 0
    startup_candle_count: int = 0

    # Minimum number of data collection periods required before trading
    min_data_periods: int = 3
    
    minimal_roi = {
        "0": -1
    }

    params_MM = None
    gamma = None
    k_bid = None
    k_ask = None
    sigma = None
    time_horizon_hours = None

    fees_HL_maker = 0.02/100.0

    nb_loop = 0

    stoploss = -0.85

    trailing_stop = False

    timeframe = '15m'

    order_types = {
        'entry': 'limit',
        'exit': 'limit',
        'stoploss': 'limit',
        "emergency_exit": "limit",
        'stoploss_on_exchange': False
    }

    order_time_in_force = {
        'entry': 'gtc',
        'exit': 'gtc'
    }

    def bot_start(self, **kwargs) -> None:
        """
        Called only once after bot instantiation.
        :param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
        """

        pairs = self.dp.current_whitelist()
        if len(pairs)!=1:
            sys.exit()

        if not self.can_short:
            logger.info('Running calculation of parameters')
            symbol = pairs[0].replace("/USDC:USDC","")
            logger.info(f"Current symbol: {symbol}")
            run_Github_djienne_AVELLANEDA_MARKET_MAKING_FREQTRADE__avellaneda__20251201_224013_param_calculation()

        self.params_MM = load_configs()
        self.gamma = self.params_MM['optimal_parameters']['gamma']
        self.k_bid = self.params_MM['market_data'].get('k_bid', self.params_MM['market_data'].get('k'))
        self.k_ask = self.params_MM['market_data'].get('k_ask', self.params_MM['market_data'].get('k'))
        self.sigma = self.params_MM['market_data']['sigma']
        # Default to 0.5 hours if not present in older JSONs
        self.time_horizon_hours = self.params_MM['optimal_parameters'].get('time_horizon_hours', 0.5)

        # Check if we have sufficient data periods
        num_periods = self.params_MM.get('current_state', {}).get('num_data_periods', 0)
        if num_periods < self.min_data_periods:
            logger.warning(f"Insufficient data collected: {num_periods}/{self.min_data_periods} periods. Trading will be disabled until more data is collected.")
        else:
            logger.info(f"Sufficient data available: {num_periods} periods collected.")
        

    def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
        """
        Called at the start of the bot iteration (one loop). For each loop, it will run populate_indicators on all pairs.
        Might be used to perform pair-independent tasks
        (e.g. gather some remote resource for comparison)
        :param current_time: datetime object, containing the current datetime
        :param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
        """

        if not self.can_short:
            if self.nb_loop%10==0:
                logger.info('Running calculation of parameters')
                run_Github_djienne_AVELLANEDA_MARKET_MAKING_FREQTRADE__avellaneda__20251201_224013_param_calculation()
            self.nb_loop = self.nb_loop + 1

        self.params_MM = load_configs()
        self.gamma = self.params_MM['optimal_parameters']['gamma']
        self.k_bid = self.params_MM['market_data'].get('k_bid', self.params_MM['market_data'].get('k'))
        self.k_ask = self.params_MM['market_data'].get('k_ask', self.params_MM['market_data'].get('k'))
        self.sigma = self.params_MM['market_data']['sigma']
        self.time_horizon_hours = self.params_MM['optimal_parameters'].get('time_horizon_hours', 0.5)

    def informative_pairs(self):
        """
        """
        return []

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        """
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        Only allow entry if we have sufficient data collected by the data collector.
        """
        # Check if parameters are loaded and we have enough collected data periods
        if self.params_MM is not None and self.sigma is not None:
            # Check if we have enough data periods from the data collector
            num_periods = self.params_MM.get('current_state', {}).get('num_data_periods', 0)
            if num_periods >= self.min_data_periods:
                dataframe.loc[:, 'enter_long'] = 1
            else:
                logger.warning(f"Insufficient data periods: {num_periods}/{self.min_data_periods}. Waiting for more data collection.")
                dataframe.loc[:, 'enter_long'] = 0
        else:
            dataframe.loc[:, 'enter_long'] = 0
        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        """
        """
        dataframe.loc[:, 'exit_long'] = 0
        return dataframe
    
    def get_mid_price(self, pair: str, fallback_rate: float) -> float:
        """
        Get effective mid price from orderbook based on $1000 depth.
        Fallback to best mid or provided rate if orderbook unavailable or insufficient.
        """
        # Request deeper orderbook to find $1000 depth
        orderbook = self.dp.orderbook(pair, maximum=50)
        
        if not orderbook or 'bids' not in orderbook or 'asks' not in orderbook:
            return fallback_rate
            
        THRESHOLD = 1000.0
        
        # Naive Mid Calculation (Top of Book)
        best_bid = orderbook['bids'][0][0] if len(orderbook['bids']) > 0 else None
        best_ask = orderbook['asks'][0][0] if len(orderbook['asks']) > 0 else None
        naive_mid = (best_bid + best_ask) / 2 if best_bid and best_ask else None
        
        # Calculate Effective Bid
        effective_bid = None
        cum_val = 0.0
        # Bids are sorted high to low
        for price, amount in orderbook['bids']:
            cum_val += price * amount
            if cum_val >= THRESHOLD:
                effective_bid = price
                break
        
        # Fallback to best bid if threshold not reached
        if effective_bid is None and len(orderbook['bids']) > 0:
            effective_bid = orderbook['bids'][0][0]
            
        # Calculate Effective Ask
        effective_ask = None
        cum_val = 0.0
        # Asks are sorted low to high
        for price, amount in orderbook['asks']:
            cum_val += price * amount
            if cum_val >= THRESHOLD:
                effective_ask = price
                break
                
        # Fallback to best ask if threshold not reached
        if effective_ask is None and len(orderbook['asks']) > 0:
            effective_ask = orderbook['asks'][0][0]
            
        if effective_bid is not None and effective_ask is not None:
            effective_mid = (effective_bid + effective_ask) / 2
            
            # Log comparison
            if naive_mid:
                diff_pct = abs(effective_mid - naive_mid) / naive_mid * 100
                logger.info(f"Price Check | Naive Mid: {naive_mid:.4f} | Effective Mid: {effective_mid:.4f} | Diff: {diff_pct:.4f}%")
            
            return effective_mid
        else:
            return fallback_rate
     
    def custom_entry_price(self, pair: str, current_time: datetime, proposed_rate: float,
                           entry_tag: str, side: str, **kwargs) -> float:

        if self.sigma is None or self.gamma is None or self.params_MM is None:
            return None

        if side!="long":
            return None
        
        mid_price = self.get_mid_price(pair, proposed_rate)
        symbol = pair.replace("/USDC:USDC","")
        open_trades = Trade.get_open_trades()
        total_quote_position = sum([float(trade.open_rate) * float(trade.amount) for trade in open_trades])
        total_capital = self.wallets.get_total(self.config['stake_currency'])
        # q_inventory_exposure = total_quote_position / total_capital if total_capital > 0 else 0
        q_inventory_exposure = 0.0
        r_buy, r_sell = calculate_optimal_spreads(mid_price,self.sigma,self.k_bid,self.k_ask,self.gamma,self.time_horizon_hours/24.0,q_inventory_exposure,self.fees_HL_maker)

        return r_buy

    def custom_exit_price(self, pair: str, trade: Trade,
                        current_time: datetime, proposed_rate: float,
                        current_profit: float, exit_tag: str, **kwargs) -> float:
        
        if self.sigma is None or self.gamma is None or self.params_MM is None:
            return None

        if trade.is_short:
            return None

        mid_price = self.get_mid_price(pair, proposed_rate)
        symbol = pair.replace("/USDC:USDC","")
        open_trades = Trade.get_open_trades()
        total_quote_position = sum([float(trade.open_rate) * float(trade.amount) for trade in open_trades])
        total_capital = self.wallets.get_total(self.config['stake_currency'])
        # q_inventory_exposure = total_quote_position / total_capital if total_capital > 0 else 0
        q_inventory_exposure = 0.0
        r_buy, r_sell = calculate_optimal_spreads(mid_price,self.sigma,self.k_bid,self.k_ask,self.gamma,self.time_horizon_hours/24.0,q_inventory_exposure,self.fees_HL_maker)

        return r_sell

    def adjust_entry_price(self, trade: Trade, order: Order, pair: str,
                            current_time: datetime, proposed_rate: float, current_order_rate: float,
                            entry_tag: str, side: str, **kwargs) -> float:
        
        if self.sigma is None or self.gamma is None or self.params_MM is None:
            return None

        if trade.is_short:
            return None
        
        mid_price = self.get_mid_price(pair, proposed_rate)
        symbol = pair.replace("/USDC:USDC","")
        open_trades = Trade.get_open_trades()
        total_quote_position = sum([float(trade.open_rate) * float(trade.amount) for trade in open_trades])
        total_capital = self.wallets.get_total(self.config['stake_currency'])
        # q_inventory_exposure = total_quote_position / total_capital if total_capital > 0 else 0
        q_inventory_exposure = 0.0
        r_buy, r_sell = calculate_optimal_spreads(mid_price,self.sigma,self.k_bid,self.k_ask,self.gamma,self.time_horizon_hours/24.0,q_inventory_exposure,self.fees_HL_maker)

        return r_buy

    def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
                    current_profit: float, **kwargs):
        return "always_exit"

    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:
        lev = 1
        logger.info(f"Using leverage: {lev}. Should not be changed.")
        return lev

    # @property
    # def protections(self):
    #     return [
    #         {
    #             "method": "MaxDrawdown",
    #             "lookback_period": 10080,  # 1 week
    #             "trade_limit": 0,  # Evaluate all trades since the bot started
    #             "stop_duration_candles": 10000000,  # Stop trading indefinitely
    #             "max_allowed_drawdown": 0.05  # Maximum drawdown of 5% before stopping
    #         },
    #     ]
