#!/user/bin/env python
# -*- coding: utf-8 -*-
"""
Copyright (C) 2024 Galaxy Technologies
Licensed under the Apache License, Version 2.0
"""
import bisect
from contextlib import suppress
import datetime
from typing import Callable
from functools import partial
import numpy as np
from pricelib.common.utilities.enums import StatusType, ExerciseType, EngineType
from pricelib.common.date_handler import (global_evaluation_date, Schedule, CN_CALENDAR, AnnualDays)
from pricelib.common.utilities.patterns import Observer, global_auto_update_status
from pricelib.common.utilities.utility import time_this, logging
from pricelib.common.product_base.option_base import OptionBase

from .european_snowball import EuropeanSnowball
from .floored_snowball import FlooredSnowball
from pricelib.products.digital.digital_option import DigitalOption
from pricelib.pricing_engines.mc_engines import MCPhoenixEngine
from pricelib.pricing_engines.fdm_engines import FdmPhoenixEngine, FdmDigitalEngine, FdmSnowBallEngine
from pricelib.pricing_engines.integral_engines import (
    QuadPhoenixEngine, QuadDigitalEngine, QuadEuroSnowEngine, QuadSnowballEngine)

PHOENIX_ENGINE_DICT = {
    EngineType.QuadEngine: {
        'DigitalOption': QuadDigitalEngine,
        'EuropeanSnowball': QuadEuroSnowEngine,
        'FlooredSnowball': QuadSnowballEngine},
    EngineType.PdeEngine: {
        'DigitalOption': FdmDigitalEngine,
        'EuropeanSnowball': FdmSnowBallEngine,
        'FlooredSnowball': FdmSnowBallEngine},
}


class PhoenixBase(OptionBase, Observer):
    """FCN/DCN/凤凰结构定价基类
        凤凰类 AutoCallable
            较雪球式AutoCallable，增加派息价格，少了敲出和红利票息
            每个派息（敲出）观察日，如果价格高于派息价格，派固定利息，如果发生敲出，合约提前结束；
            发生敲入后，派息方式不变，到期如果未敲出，结构为看跌空头或熊市价差空头
        FCN(Fixed Coupon Note, 固定票息票据)/DCN(Dynamic Coupon Note, 动态票息票据)/Phoenix凤凰票据
            FCN：无派息价格（派息价格为0），每月固定派息；到期日观察是否敲入
            DCN：有派息价格，每个观察日如果价格高于派息价格，派固定利息；到期日观察是否敲入
            Phoenix：有派息价格，每个观察日如果价格高于派息价格，派固定利息；每日观察是否敲入
    """

    def __init__(self, s0,
                 barrier_out=None, barrier_yield=None, coupon=None, barrier_in=None, parti_in=1, margin_lvl=1,
                 strike_upper=None, strike_lower=0, obs_dates=None, pay_dates=None, div_obs_dates=None, lock_term=None,
                 in_obs_type=ExerciseType.American, status=StatusType.NoTouch, div_history=None, engine=None,
                 maturity=None, start_date=None, end_date=None, trade_calendar=CN_CALENDAR, annual_days=AnnualDays.N365,
                 t_step_per_year=243, stoch_process=None, s=None, r=None, q=None, vol=None, engine_method=None):
        """构造函数
        Args:
            s0: float，标的初始价格
            barrier_out: List[float]，敲出障碍价，绝对值/百分比，长度需要与敲出观察日数一致
                         若不是常数，为阶梯式凤凰（降敲）
            barrier_yield: List[float]，派息边界价格，绝对值/百分比，长度需要与派息观察日数一致
                           凤凰和DCN，barrier_yield一般等于敲入价格；FCN每月固定派息，barrier_yield等于0
            coupon: List[float]，派息票息，百分比，非年化
                    数组list，长度需要与派息观察日数一致，为阶梯式浮动票息（降息或双降）凤凰
            barrier_in: List[float]，敲入障碍价，绝对值/百分比
                    若不是常数，为变敲入型雪球，每个敲出观察日对应一个敲入价，在这个敲出观察日和上个敲出观察日之间，敲入价为该敲入价
                    对于FCN，进到期日观察是否敲入，到期日前的敲入价都是0
            parti_in: float，敲入后参与率，限损
            margin_lvl: float，保证金比例，默认为1，即无杠杆
            strike_upper: float，敲入发生后(熊市价差)的高行权价，即OTM行权价(<s0)，普通凤凰敲入发生后的高行权价为期初价格s0
            strike_lower: float，敲入发生后(熊市价差)的低行权价，即保底边界，越高提供的保护越多，值为0时不保底
            lock_term: int，锁定期，单位为月，锁定期内不触发敲出
            status: 敲入敲出状态，StatusType枚举类，默认为NoTouch，已敲入未敲出为DownTouch，已敲出为UpTouch
            div_history: List[datetime.date]，历史派息列表，记录已经发生派息的派息观察日div_obs_dates
            in_obs_type: 敲入观察类型，ExerciseType枚举类，默认为American每日观察；European为仅到期日观察是否敲入
        时间参数: 要么输入年化期限，要么输入起始日和到期日；敲出观察日和票息支付日可缺省
            maturity: float，年化期限
            start_date: datetime.date，起始日
            end_date: datetime.date，到期日
            obs_dates: List[datetime.date]，敲出观察日，可缺省，缺省时会自动生成每月观察的敲出日期序列(已根据节假日调整)
            pay_dates: List[datetime.date]，票息支付日，可缺省，缺省时会自动生成每月观察的派息日期序列(已根据节假日调整)
            div_obs_dates: List[datetime.date]，派息观察日，可缺省，缺省时会自动生成每月观察的派息观察日期序列（已根据节假日调整）
            trade_calendar: 交易日历，Calendar类，默认为中国内地交易日历
            annual_days: int，每年的自然日数量
            t_step_per_year: int，每年的交易日数量
        可选参数:
            engine: 定价引擎，PricingEngine类
                    蒙特卡洛: MCPhoenixEngine
                    PDE: FdmPhoenixEngine
                    积分法: QuadPhoenixEngine
            engine_method: str, 定价引擎种类。若未输入engine，可以用engine_method快捷生成指定定价引擎。可选MC/MonteCarlo、PDE/FDM
            若未提供引擎engine、也未指定引擎种类engine_method的情况下，如果提供了 随机过程 或 标的价格、无风险利率、分红/融券率、波动率，
            则默认使用Quad定价引擎 QuadPhoenixEngine
            stoch_process: 随机过程StochProcessBase对象
            s: float，标的价格
            r: float，无风险利率
            q: float，分红/融券率
            vol: float，波动率
        """
        super().__init__()
        self.s0 = s0  # 标的初始价格
        self.trade_calendar = trade_calendar  # 交易日历
        self.annual_days = annual_days
        assert maturity is not None or (start_date is not None and end_date is not None), "Error: 到期时间或起止时间必须输入一个"
        self.start_date = start_date if start_date is not None else global_evaluation_date()  # 起始时间
        self.end_date = end_date if end_date is not None else (
                self.start_date + datetime.timedelta(days=round(maturity * annual_days.value)))  # 结束时间
        if self.trade_calendar.isholiday(self.end_date):  # 如果到期日不是交易日，向后顺延一个交易日
            self.end_date = self.trade_calendar.advance(self.end_date, datetime.timedelta(days=1))
        if maturity is None:
            self.maturity = (end_date - start_date).days / annual_days.value
        else:
            self.maturity = maturity

        # 敲出观察日
        if obs_dates is None:
            self.obs_dates = Schedule(trade_calendar=trade_calendar, start=self.start_date,
                                      end=self.end_date, freq='m', lock_term=lock_term)  # 有锁定期
            self.end_date = self.obs_dates.date_schedule[-1]  # 修正整个产品的到期时间
        else:
            self.obs_dates = Schedule(trade_calendar=trade_calendar, date_schedule=obs_dates)
        # 派息观察日
        if div_obs_dates is None:
            self.div_obs_dates = Schedule(trade_calendar=trade_calendar, start=self.start_date,
                                          end=self.end_date, freq='m', lock_term=1)  # 无锁定期
        else:
            self.div_obs_dates = Schedule(trade_calendar=trade_calendar, date_schedule=div_obs_dates)
        # 支付日
        if pay_dates is None:
            self.pay_dates = self.div_obs_dates  # 默认等于派息观察日
        else:
            self.pay_dates = Schedule(trade_calendar=trade_calendar, date_schedule=pay_dates)
        assert len(self.div_obs_dates.date_schedule) == len(self.pay_dates.date_schedule), "派息观察日与支付日期列表长度应该相等!"
        self.t_step_per_year = t_step_per_year
        if lock_term is None:
            self.lock_term = len(self.div_obs_dates.date_schedule) - len(self.obs_dates.date_schedule) + 1
        else:
            self.lock_term = lock_term  # 锁定期
            assert lock_term - 1 == len(self.div_obs_dates.date_schedule) - len(self.obs_dates.date_schedule), "敲出观察日与派息观察日的个数之差与锁定期不匹配!"
        # 敲出线
        if isinstance(barrier_out, (int, float, np.int32, np.float64)):
            self.barrier_out = (np.ones(len(self.obs_dates.date_schedule)) * barrier_out).astype(float)  # 敲出线与敲出观察日等长
        elif isinstance(barrier_out, (list, np.ndarray)):
            self.barrier_out = np.array(barrier_out)
        else:
            raise ValueError(f'敲出价类型为{type(barrier_out)}，仅支持int/float/list/np.ndarray，请检查')
        # 敲入线
        if isinstance(barrier_in, (int, float, np.int32, np.float64)):
            self.barrier_in = np.ones(len(self.div_obs_dates.date_schedule)) * barrier_in  # 敲入线与派息观察日等长
        elif isinstance(barrier_in, (list, np.ndarray)):
            self.barrier_in = np.array(barrier_in)
        else:
            raise ValueError(f'敲出价类型为{type(barrier_out)}，仅支持int/float/list/np.ndarray，请检查')
        # 派息边界价格
        if barrier_yield is None:
            self.barrier_yield = self.barrier_in  # 派息线默认等于敲入线
        else:
            if isinstance(barrier_yield, (int, float, np.int32, np.float64)):
                self.barrier_yield = np.ones(len(self.div_obs_dates.date_schedule)) * barrier_yield  # 派息线与派息观察日等长
            elif isinstance(barrier_yield, (list, np.ndarray)):
                self.barrier_yield = np.array(barrier_yield)
            else:
                raise ValueError(f'派息价类型为{type(barrier_yield)}，仅支持int/float/list/np.ndarray，请检查')
        # 票息(绝对收益，默认为月度)
        if isinstance(coupon, (int, float, np.int32, np.float64)):
            self.coupon = np.ones(len(self.pay_dates.date_schedule)) * coupon  # 票息数组与派息支付日等长
        elif isinstance(coupon, (list, np.ndarray)):
            self.coupon = np.array(coupon)
        else:
            raise ValueError(f'派息比例类型为{type(coupon)}，仅支持int/float/list/np.ndarray，请检查')
        self.parti_in = parti_in  # 下跌参与率
        self.margin_lvl = margin_lvl  # 预付金比例
        self.strike_upper = s0 if strike_upper is None else strike_upper  # 敲入后高执行价
        self.strike_lower = strike_lower  # 低行权价
        self.status = status
        # 派息历史，记录已经发生派息的派息观察日div_obs_dates
        if div_history is not None:
            self.div_history = Schedule(trade_calendar=trade_calendar, start=self.start_date, end=self.end_date,
                                        freq='m', lock_term=1, date_schedule=div_history)
        else:
            self.div_history = Schedule(trade_calendar=trade_calendar, start=self.start_date, end=self.end_date,
                                        freq='m', lock_term=1, date_schedule=[])
        self.in_obs_type = in_obs_type  # 敲入观察为欧式，到期观察敲入；美式，每日观察敲入
        self.stop_date = self.end_date  # 停止日期，默认等于到期日。发生敲出事件，停止日期更新为敲出日期

        # 凤凰/DCN拆分成的25个子期权self.sub_products, 用self.init_sub_products()方法初始化
        self.sub_products = None
        self.init_sub_products()
        # 反算票息
        self.calc_coupon = partial(self.calc_param, reset_param_method=self.reset_coupon)

        if engine is not None:
            self.set_pricing_engine(engine)
        else:
            if engine_method is None:
                default_engine = QuadPhoenixEngine  # 默认定价引擎
            elif engine_method in ["PDE", "FDM"]:
                default_engine = FdmPhoenixEngine
            elif engine_method in ["MC", "MonteCarlo"]:
                default_engine = MCPhoenixEngine
            elif engine_method == "QUAD":
                default_engine = QuadPhoenixEngine
            else:
                raise NotImplementedError(f"不支持的engine_method{engine_method}, 凤凰、FCN、DCN仅支持MC/PDE/QUAD")
            if stoch_process is not None:
                self.set_pricing_engine(default_engine(stoch_process))
            elif s is not None and r is not None and q is not None and vol is not None:
                self.set_pricing_engine(default_engine(s=s, r=r, q=q, vol=vol))

    def set_pricing_engine(self, engine):
        with suppress(AttributeError, ValueError):
            self.engine.process.spot.remove_observer(self)
        self.engine = engine
        self.engine.process.spot.add_observer(self)
        logging.info(f"{self}当前定价方法为{engine.engine_type.value}")

        self_engine_type = self.engine.engine_type
        if self_engine_type in [EngineType.PdeEngine, EngineType.QuadEngine]:
            # PDE-FDM有限差分法 或 QUAD积分法，需要为子产品设置对应的定价引擎
            for sub_product in self.sub_products:
                sub_product_class = sub_product.__class__.__name__
                sub_engine_type = PHOENIX_ENGINE_DICT[self_engine_type][sub_product_class]
                sub_engine = sub_engine_type(self.engine.process)
                sub_product.set_pricing_engine(sub_engine)
        return

    def remove_self(self):
        """删除对象自己，del自己之前，先将自己从被观察者的列表中移除"""
        self.engine.process.spot.remove_observer(self)
        del self

    def update(self, observable, *args, **kwargs):
        """收到标的价格变化的通知时，自动更新自动赎回结构是否已经敲入或敲出; 以及记录是否发生派息"""
        if global_auto_update_status():
            calculate_date = global_evaluation_date()
            if calculate_date < self.start_date or calculate_date > self.stop_date:
                return  # 如果在起始日前或终止日之后，状态维持不变
            if observable == self.engine.process.spot:
                if calculate_date in self.div_obs_dates.date_schedule:  # 如果估值日是派息观察日
                    div_obs_idx = self.div_obs_dates.date_schedule.index(calculate_date)
                    if observable.data >= self.barrier_yield[div_obs_idx]:  # 如果标的价格大于等于派息价，记录发生派息的观察日
                        self.div_history.date_schedule.append(calculate_date)
                if calculate_date in self.obs_dates.date_schedule:  # 如果估值日是敲出观察日
                    obs_idx = self.obs_dates.date_schedule.index(calculate_date)
                    if observable.data >= self.barrier_out[obs_idx]:  # 如果标的价格大于等于敲出价，敲出
                        self.status = StatusType.UpTouch
                        self.stop_date = calculate_date
                        return
                if self.status in [StatusType.DownTouch, StatusType.UpTouch]:  # 如果已敲入/已敲出，维持原来的状态
                    return
                if self.status == StatusType.NoTouch:  # 如果未敲入未敲出
                    if self.in_obs_type == ExerciseType.American:  # 每日观察敲入(凤凰)
                        obs_idx = bisect.bisect_left(self.obs_dates.date_schedule, calculate_date)
                        if observable.data <= self.barrier_in[obs_idx]:  # 且标的价格小于等于敲入价，敲入
                            self.status = StatusType.DownTouch
                            return
                    elif self.in_obs_type == ExerciseType.European:  # 到期观察敲入(DCN/FCN)
                        if (calculate_date == self.end_date) and (observable.data <= self.barrier_in[-1]):
                            self.status = StatusType.DownTouch
                            return
                    else:
                        raise ValueError(f"不支持的敲入观察类型{self.in_obs_type}，仅支持美式(凤凰)/欧式(DCN/FCN)")

    def __repr__(self):
        """返回期权的描述"""
        return "FCN/DCN/Phoenix"

    @time_this
    def price(self, t: datetime.date = None, spot=None):
        """计算期权价格
        Args:
            t: datetime.date，计算期权价格的日期
            spot: float，标的价格
        Returns: 期权现值
        """
        t = global_evaluation_date() if t is None else t
        self.validate_parameters(t=t)
        price = self.engine.calc_present_value(prod=self, t=t, spot=spot)
        return price

    def calc_param(self, reset_param_method: Callable, target_value: float = None, t: datetime.date = None, spot=None,
                   high=2., low=0., thres=0.001, max_iters=20, *args, **kwargs):
        """根据目标估值Y，用二分法反算衍生品参数X
        反算票息：calc_coupon = partial(calc_param, reset_param_method=self.reset_coupon)
        """
        # 若未指定估值时间和价位，则默认按期初估值反算，公允估值=名义本金(pricelib估值不含名本，因此默认target_value=期初价格s0)
        if (t is None) and (spot is None):
            t, spot = self.start_date, self.s0
            self.status = StatusType.NoTouch  # 重置期初状态
        target_value = self.s0 * self.margin_lvl if target_value is None else target_value
        return super().calc_param(reset_param_method, target_value, t, spot, high, low, thres, max_iters, *args, **kwargs)

    def reset_coupon(self, coupon, *args, **kwargs):
        """重新设置票息值，各个变种凤凰/DCN/FCN可重写此方法"""
        if self.engine.engine_type in [EngineType.PdeEngine, EngineType.QuadEngine]:
            # PDE-FDM 或 积分法
            digital_num = 0
            for sub_product in self.sub_products:
                if sub_product.__class__.__name__ == "DigitalOption":
                    digital_num += 1
            for i, sub_product in enumerate(self.sub_products[:digital_num]):
                # 二元子产品定价重新设置票息值
                sub_product.rebate = coupon * self.s0
            for j, sub_product in enumerate(self.sub_products[digital_num:-1]):
                # 欧式雪球子产品重新设置票息值
                sub_product.coupon_out = np.concatenate((np.zeros(j), np.array([coupon])))
                sub_product.coupon_div = coupon
        else:  # 蒙特卡洛模拟
            self.coupon = np.array([coupon for x in self.coupon])

    def init_sub_products(self):
        """初始化凤凰/DCN拆分成的25个子期权
        凤凰期权可以拆分为25个期权：
            拆分0：敲入70%，敲出100%，24个月不锁定，100%保证金，敲出票息为0%的经典雪球期权；敲入观察为每日观察；
                 (如果是DCN，拆分0改为欧式雪球，敲入观察为到期观察)
            拆分1：敲入70%，敲出100%，期限1个月，每月观察，0%保证金，保本雪球，1个月敲出票息为绝对0.6%，
                  未敲入未敲出票息为绝对0.6%，敲入为欧式观察；
            拆分2：敲入70%，敲出100%，期限2个月，每月观察，0%保证金，保本雪球，第1个月敲出票息为0%，第2个月敲出票息为绝对0.6%，
                  未敲入未敲出票息为绝对0.6%，敲入为欧式观察；
            ……
            拆分24：敲入70%，敲出100%，期限24个月，每月观察，0%保证金，保本雪球，前23个月敲出票息为0%，第24个月敲出票息为绝对0.6%，
                  未敲入未敲出票息为绝对0.6%，敲入为欧式观察；
        具体情形验证：
            （1）24个月内未敲入，且在某月发生敲出事件：假设第7个月敲出，拆分0敲出，收益为0；
                拆分1-6都未敲入未敲出，获得0.6%的派息；拆分7到期敲出，获得0.6%的派息；
                拆分8-24期间敲出，收益为0；客户一共获得7*0.6%派息，与凤凰期权收益一致；
            （2）24个月内未敲入（未跌破派息边界）且未敲出：拆分0收益为0，拆分1-24都未敲入未敲出，
                每个月都获得0.6%的派息，客户一共获得24*0.6%的派息，与凤凰期权收益一致；
            （3）24个月内发生敲入事件且在到期时未敲出。期权发生敲入事件，则存续期间必有部分月份跌破70%敲入线，
                假设24个月中第7和第8个月跌破70%，其余月份均在70%以上。拆分0敲入未敲出，需承担标的证券下跌的亏损；
                拆分1-6未敲入未敲出，每月获得0.6%派息；拆分7-8到期敲入，收益为0；
                拆分9-24由于是欧式观察，均未敲入未敲出，每月获得0.6%派息；
                客户需承担标的证券下跌的损失和22*0.6%的派息，与凤凰期权收益一致；
            （4）24个月内发生敲入事件且在存续期间敲出。同上，假设24个月中第7和第8个月跌破70%，并在第11个月敲出。
                拆分0敲入后敲出，收益为0；拆分1-6未敲入未敲出，每月获得0.6%派息；拆分7-8到期敲入，收益为0；
                拆分9-10未敲入未敲出，每月获得0.6%派息；拆分11到期敲出，获得0.6%派息；拆分12-24期间敲出，收益为0；
                客户一共获得9*0.6%的派息，与凤凰期权收益一致；
        在不同情形下，拆分期权的收益均与凤凰期权一致，从而拆分期权的估值之和与凤凰期权的估值相等。
        """
        # 拆分成 len(div_obs_dates)+1 个子结构
        self.sub_products = list()
        digital_num = self.lock_term - 1
        for i in range(digital_num):
            # 敲出锁定期内，相当于欧式二元期权，在派息线上方即获得月度绝对票息收益
            sub_producti = DigitalOption(start_date=self.start_date, end_date=self.div_obs_dates.date_schedule[i],
                                         strike=self.barrier_yield[i], rebate=self.coupon[i] * self.s0,
                                         exercise_type=ExerciseType.European, trade_calendar=self.trade_calendar,
                                         discrete_obs_interval=1 / self.t_step_per_year,
                                         t_step_per_year=self.t_step_per_year)
            self.sub_products.append(sub_producti)
        for j in range(len(self.obs_dates.date_schedule)):
            # 敲出观察日 & 派息观察日，相当于欧式雪球，敲入不亏损，到期未敲入获得绝对票息
            sub_coupon = np.concatenate((np.zeros(j), np.array([self.coupon[j + digital_num]])))
            sub_productj = EuropeanSnowball(s0=self.s0, start_date=self.start_date,
                                            end_date=self.div_obs_dates.date_schedule[j + digital_num],
                                            # fixme: 为了避免 div_obs_dates 与 obs_dates不一致产生的问题，暂时取派息观察日
                                            obs_dates=self.div_obs_dates.date_schedule[digital_num: j + self.lock_term],
                                            pay_dates=self.pay_dates.date_schedule[digital_num: j + self.lock_term],
                                            parti_in=0, margin_lvl=0, trade_calendar=self.trade_calendar,
                                            barrier_out=self.barrier_out[:j + 1],
                                            barrier_in=self.barrier_yield[j + digital_num],
                                            coupon_out=sub_coupon, coupon_div=self.coupon[j + digital_num],
                                            trigger=True, lock_term=digital_num + 1, status=StatusType.NoTouch,
                                            strike_lower=self.s0, engine=None, t_step_per_year=self.t_step_per_year)
            self.sub_products.append(sub_productj)
        # 每日观察敲入结构，相当于一个没有票息的保底雪球
        if self.in_obs_type == ExerciseType.American:  # 凤凰，每日观察敲入
            knock_in_snowball = FlooredSnowball
            _barrier_in = self.barrier_in[digital_num:]  # todo: 雪球目前只支持在敲出观察日改变敲入价格, 不支持锁定期内敲入价改变
        elif self.in_obs_type == ExerciseType.European:  # DCN，到期观察敲入
            knock_in_snowball = EuropeanSnowball
            _barrier_in = self.barrier_in[-1]
        else:
            raise ValueError(f"不支持的敲入观察类型{self.in_obs_type}，仅支持美式(凤凰)/欧式(DCN/FCN)")

        sub_product0 = knock_in_snowball(barrier_out=self.barrier_out, barrier_in=_barrier_in, s0=self.s0,
                                         lock_term=digital_num + 1, strike_lower=self.strike_lower,
                                         coupon_out=0, coupon_div=0, margin_lvl=self.margin_lvl, parti_in=self.parti_in,
                                         maturity=self.maturity, start_date=self.start_date, end_date=self.end_date,
                                         obs_dates=self.obs_dates.date_schedule,
                                         pay_dates=self.pay_dates.date_schedule[digital_num:],
                                         trade_calendar=CN_CALENDAR, t_step_per_year=self.t_step_per_year,
                                         engine=None, status=self.status)
        self.sub_products.append(sub_product0)
        return
