import cvxpy as cp
import copy
from numpy import linalg as LA
import os
import traceback
import glob
import numpy as np
import pandas as pd
import math
from openpyxl import load_workbook

from ..signal.engine     import *
from ..signal.interface  import *
from ..risk              import *
from ..domain            import *
from model.trading.EG import EG

from model.trading.process_gqr_specret import process_gqr_specret
"""
from model.trading.periscope_alpha import (
    applyPeriscopeOverlay,
    applyPeriscopeAlphaConditioning,
)

"""

# === Axioma specific covariance integration ===
GQR_SPECRET_DIR = "/data/gqr_specret_cov"


# Set pandas display options
pd.set_option('display.max_rows', None)         # Show all rows
pd.set_option('display.max_columns', None)      # Show all columns
pd.set_option('display.width', 1000)            # Set wider console width
pd.set_option('display.max_colwidth', None)     # Show full content in each column
pd.set_option('display.float_format', '{:.4f}'.format)  # Show floating point numbers with 4 decimals

def matrix_shrink(Q):
    """ Take Q and reapprox using subset of singular values """
    # shrink matrix
    U, S, V = LA.svd(Q) 
    num_sing_values = S.shape[0]
    U = U[:, :num_sing_values]

    # log some singular values
    PyLog.info(f"matrix_shrink sing values top5 {S[0:5]} bot5 {S[-5:]}")

    # use at least portion of the singular values, and always at least K but not more than the rank of the matrix
    # very cut down settings we have used in some tests:  portion= 1/8; K = 10
    # typical keep most settings portion= 7/8; K= 20
    portion = 7/8
    K = 20
    num_sing_vaule_to_use = max(int(num_sing_values*portion), K)
    r = min(num_sing_values, num_sing_vaule_to_use)
    PyLog.info(f"matrix_shrink got {num_sing_values} singular values. portion:{portion:.3f} K:{K} reconstructing with {r}. matrix shpe {Q.shape} first/last  {S[0]/S[4] :.2f}")
    S = np.diag(S)
    R = U[:, :r] @ S[:r, :r] @ V[:r, :]
    return R

class RebalConfig:

    dctDefaultOptParams = {
        'SCS': {'maxIter': 200_000,
                'cycleIter': 5_000,
                'targetTolerance': 1e-4,
                'toleranceRange': [7e-4, 1e-3, 2e-3, 3e-3, 4e-3, 5e-3]},
        'ECOS': {'maxIter': 20_000}
    }

    DEFAULT_CONFIG = {'configName': 'DEFAULT',
                      'stratName': 'strat_asia_v1',
                      'maxLeverage': +4.00,
                      'minNetExposure': -0.05,
                      'maxNetExposure': +0.40,
                      'minBetaAdjNet': -0.01,
                      'maxBetaAdjNet': +0.01,
                      'gamma': 1.8,
                      'adaptiveGamma': False,
                      'adaptiveGammaTargetRisk': 0.10,
                      'adaptiveGammaTolerance': 0.0025,
                      'tau': 4.0,
                      'maxRisk': 0.13,
                      'offDiagRisk': False,
                      'splitLongShort': False,
                      'splitLongShortStartDate': PyDate.asDate(20220615),
                      'bcostLookback': 8,
                      'maxShortUtilization': 0.8,
                      'pbList': None,
                      'bcostMultiplierName': None,
                      'HAUM': False,
                      'hypotheticalAUM': 100_000_000,
                      'univwtThreshold': 0.25,
                      'holdingThreshold': 0.00005,
                      'rmodelName': 'risk_model',
                      'tmodelName': 'tcost_model',
                      'impactCostScale': 1.0,
                      'alphaName': 'alpha',
                      'tradeUniverseName': None,
                      'themeBoundName': 'trading_alpha_theme_exposure_bounds',
                      'fcostName': 'trading_long_financing_cost',
                      'bcostName': 'trading_short_borrow_cost',
                      'defaultFCost': 0.005,
                      'defaultBCost': 0.15,
                      'longBoundName': 'trading_position_bounds_long',
                      'shortBoundName': 'trading_position_bounds_short',
                      'defaultLongBound': 0.000025,
                      'defaultShortBound': 0.000025,
                      'liquidityName': 'adv_usdm_blended',
                      'defaultLiquidityUSDM': 1.0,
                      'maxLiquidityBoundLong': None,
                      'maxLiquidityBoundShort': None,
                      'applyShortBan': True,
                      'applyExchangeHolidays': False,
                      'advName': 'adv_usdm_blended',
                      'countryBoundName': 'trading_country_bounds_frame',
                      'industryBoundName': 'trading_industry_bounds_frame',
                      'sizeBoundName': 'trading_size_bounds_frame',
                      'targetTolerance': 1e-4,
                      'toleranceRange': [7e-4, 1e-3, 2e-3, 3e-3, 4e-3, 5e-3],
                      'minShortAvailM': 0,
                      'shrinkfcov' : False,
                      'enable_gs_stability_score' : False,
                      'subdir':None,
                      'periscopeMode': 'UNWIND',                  # OFF | UNWIND | HARD | SOFT_PENALTY
                      'periscopeDir': '/data/periscope',
                      'periscopeWindowDays': 30,
                      'periscopeFadeFallbackDays': 10,
                      'periscopeMinAbsDaysToTrade': 1.0,
                      'periscopeMinConviction': 'High',
                      'periscopeEnableLogging': True,
                      'periscopeLogDir': '/data/log/periscope',
                      'periscopeLogIncludeChangeSheet': False,
                      'periscopePenaltyLambda': 5.0,
                      'tightCountry' : False,}

    @classmethod
    def defaultConfig(cls, parameter):
        return cls.DEFAULT_CONFIG[parameter]
    
    def __init__(self,
                 configName              = None,
                 stratName               = None,
                 maxLeverage             = None,
                 minNetExposure          = None,
                 maxNetExposure          = None,
                 minBetaAdjNet           = None,
                 maxBetaAdjNet           = None,
                 gamma                   = None,
                 adaptiveGamma           = None,
                 adaptiveGammaTargetRisk = None,
                 adaptiveGammaTolerance  = None,
                 tau                     = None,
                 maxRisk                 = None,
                 offDiagRisk             = None,
                 splitLongShort          = None,
                 splitLongShortStartDate = None,
                 maxShortUtilization     = None,
                 pbList                  = None,
                 bcostMultiplierName     = None,
                 HAUM                    = None,
                 hypotheticalAUM         = None,
                 bcostLookback           = None,
                 univwtThreshold         = None,
                 holdingThreshold        = None,
                 rmodelName              = None,
                 tmodelName              = None,
                 impactCostScale         = None,
                 alphaName               = None,
                 tradeUniverseName       = None,
                 themeBoundName          = None,
                 fcostName               = None,
                 bcostName               = None,
                 defaultFCost            = None,
                 defaultBCost            = None,
                 longBoundName           = None,
                 shortBoundName          = None,
                 liquidityName           = None,
                 defaultLiquidityUSDM    = None,
                 maxLiquidityBoundLong   = None,
                 maxLiquidityBoundShort  = None,
                 defaultLongBound        = None,
                 defaultShortBound       = None,
                 applyShortBan           = None,
                 applyExchangeHolidays   = None,
                 advName                 = None,
                 maxAdvProp              = None,
                 countryBoundName        = None,
                 industryBoundName       = None,
                 sizeBoundName           = None,
                 targetTolerance         = None,
                 toleranceRange          = None,
                 minShortAvailM          = None,
                 shrinkfcov              = None,
                 enable_gs_stability_score = None,
                 subdir=None,
                 tightCountry            = None,):

        self.configName        = self.defaultConfig('configName') if configName is None else configName
        self.stratName         = self.defaultConfig('stratName') if stratName is None else stratName
        self.maxLeverage       = self.defaultConfig('maxLeverage') if maxLeverage is None else maxLeverage
        self.minNetExposure    = self.defaultConfig('minNetExposure') if minNetExposure is None else minNetExposure
        self.maxNetExposure    = self.defaultConfig('maxNetExposure') if maxNetExposure is None else maxNetExposure
        self.minBetaAdjNet     = self.defaultConfig('minBetaAdjNet') if minBetaAdjNet is None else minBetaAdjNet
        self.maxBetaAdjNet     = self.defaultConfig('maxBetaAdjNet') if maxBetaAdjNet is None else maxBetaAdjNet
        self.gamma             = self.defaultConfig('gamma') if gamma is None else gamma
        self.adaptiveGamma     = self.defaultConfig('adaptiveGamma') if adaptiveGamma is None else adaptiveGamma
        self.adaptiveGammaTargetRisk = self.defaultConfig('adaptiveGammaTargetRisk')\
            if adaptiveGammaTargetRisk is None else adaptiveGammaTargetRisk
        self.adaptiveGammaTolerance  = self.defaultConfig('adaptiveGammaTolerance')\
            if adaptiveGammaTolerance is None else adaptiveGammaTolerance
        self.tau               = self.defaultConfig('tau') if tau is None else tau
        self.maxRisk           = self.defaultConfig('maxRisk') if maxRisk is None else maxRisk
        self.offDiagRisk       = self.defaultConfig('offDiagRisk') if offDiagRisk is None else offDiagRisk
        self.splitLongShort    = self.defaultConfig('splitLongShort') if splitLongShort is None else splitLongShort
        self.splitLongShortStartDate = self.defaultConfig('splitLongShortStartDate')\
            if splitLongShortStartDate is None else splitLongShortStartDate
        self.maxShortUtilization = self.defaultConfig('maxShortUtilization')\
            if maxShortUtilization is None else maxShortUtilization
        self.pbList = self.defaultConfig('pbList') if pbList is None else pbList
        self.bcostMultiplierName = self.defaultConfig('bcostMultiplierName')\
            if bcostMultiplierName is None else bcostMultiplierName
        self.HAUM = self.defaultConfig('HAUM') if HAUM is None else HAUM
        self.hypotheticalAUM = self.defaultConfig('hypotheticalAUM')\
            if hypotheticalAUM is None else hypotheticalAUM
        self.bcostLookback     = self.defaultConfig('bcostLookback') if bcostLookback is None else bcostLookback
        self.univwtThreshold   = self.defaultConfig('univwtThreshold') if univwtThreshold is None else univwtThreshold
        self.holdingThreshold  = self.defaultConfig('holdingThreshold') if holdingThreshold is None else holdingThreshold
        self.rmodelName        = self.defaultConfig('rmodelName') if rmodelName is None else rmodelName
        self.tmodelName        = self.defaultConfig('tmodelName') if tmodelName is None else tmodelName
        self.impactCostScale   = self.defaultConfig('impactCostScale') if impactCostScale is None else impactCostScale
        self.alphaName         = self.defaultConfig('alphaName') if alphaName is None else alphaName
        self.tradeUniverseName = \
            self.defaultConfig('tradeUniverseName') if tradeUniverseName is None else tradeUniverseName
        self.themeBoundName    = themeBoundName
        self.fcostName         = self.defaultConfig('fcostName') if fcostName is None else fcostName
        self.bcostName         = self.defaultConfig('bcostName') if bcostName is None else bcostName
        self.defaultFCost      = self.defaultConfig('defaultFCost') if defaultFCost is None else defaultFCost
        self.defaultBCost      = self.defaultConfig('defaultBCost') if defaultBCost is None else defaultBCost
        self.longBoundName     = self.defaultConfig('longBoundName') if longBoundName is None else longBoundName
        self.shortBoundName    = self.defaultConfig('shortBoundName') if shortBoundName is None else shortBoundName
        self.defaultLongBound  = \
            self.defaultConfig('defaultLongBound') if defaultLongBound is None else defaultLongBound
        self.defaultShortBound = \
            self.defaultConfig('defaultShortBound') if defaultShortBound is None else defaultShortBound
        self.liquidityName     = \
            self.defaultConfig('liquidityName') if liquidityName is None else liquidityName
        self.defaultLiquidityUSDM = \
            self.defaultConfig('defaultLiquidityUSDM') if defaultLiquidityUSDM is None else defaultLiquidityUSDM
        self.maxLiquidityBoundLong = \
            self.defaultConfig('maxLiquidityBoundLong') if maxLiquidityBoundLong is None else maxLiquidityBoundLong
        self.maxLiquidityBoundShort = \
            self.defaultConfig('maxLiquidityBoundShort') if maxLiquidityBoundShort is None else maxLiquidityBoundShort
        self.applyShortBan     = self.defaultConfig('applyShortBan') if applyShortBan is None else applyShortBan
        self.applyExchangeHolidays = \
            self.defaultConfig('applyExchangeHolidays') if applyExchangeHolidays is None else applyExchangeHolidays
        self.advName           = self.defaultConfig('advName') if advName is None else advName
        self.maxAdvProp        = maxAdvProp
        self.countryBoundName  = \
            self.defaultConfig('countryBoundName') if countryBoundName is None else countryBoundName
        self.industryBoundName = \
            self.defaultConfig('industryBoundName') if industryBoundName is None else industryBoundName
        self.sizeBoundName     = self.defaultConfig('sizeBoundName') if sizeBoundName is None else sizeBoundName
        self.targetTolerance   = self.defaultConfig('targetTolerance') if targetTolerance is None else targetTolerance
        self.toleranceRange    = self.defaultConfig('toleranceRange') if toleranceRange is None else toleranceRange
        self.minShortAvailM    = self.defaultConfig('minShortAvailM') if minShortAvailM is None else minShortAvailM
        self.shrinkfcov = self.defaultConfig('shrinkfcov') if shrinkfcov is None else shrinkfcov 
        self.tightCountry = self.defaultConfig('tightCountry') if tightCountry is None else tightCountry 
        self.enable_gs_stability_score     = self.defaultConfig('enable_gs_stability_score') if enable_gs_stability_score is None else enable_gs_stability_score
        self.subdir = subdir

RebalConfig_DEFAULT = RebalConfig()

## ==================================================================================================================

class Rebalance:

    EMPTY_PORTFOLIO = pd.Series(1.0, index=['$USD'])

    @classmethod
    def saveSimScratch(cls, df, date=None, name_without_path=None):
        """ hack method for auditing and analysis
            drop the df in the sim scratch directory for post inspection 
        
            place a call whereever you want a dataframe for analysis, eg
                cls.saveSimScratch(dframe, tradeDate, "weights_early")
            args
            df: dataframe to save down
            date : if found appends to name 
            name_without_path : no path, no extension 
            """

        user = os.environ['USER']
        base = f"/home/{user}/scratch/r"
        os.makedirs(base, exist_ok=True)
        if name_without_path is None:
            name_without_path = "scratch_file"
        if date:
            date_str = date.strftime("%Y%m%d")
            filebase = f"{base}/{name_without_path}_{date_str}"
        else:
            filebase = f"{base}/{name_without_path}"
        df.to_parquet(f"{filebase}.pq")
        df.to_csv(f"{filebase}.csv")

    @classmethod
    def run(cls, rebalConfig=RebalConfig_DEFAULT, signalDate=PyDate.asDate(20191115),
            preOptWeights=EMPTY_PORTFOLIO, NAV=100000000, solver=cp.SCS, maxIter=200000, cycleIter=5000,
            verbose=True, checkDCP=True, optimal=False, tradeRestrictions=None):


        tradeDate = PyDate.nextWeekday(signalDate)
        PyLog.info('optimizing for SD:{} / TD:{}'.format(PyDate.asISO(signalDate), PyDate.asISO(tradeDate)))


        dctOptResult = dict()
        dctOptResult['rebalConfig'] = rebalConfig
        dctOptResult['signalDate']  = signalDate
        dctOptResult['preOptNAV']   = NAV

        if len(preOptWeights) == 0:
            dfWeights = pd.DataFrame({'assetKey': ['assetKey'], 'preOptWeights': [0.0]}).iloc[:0]
        else:
            dfWeights = preOptWeights.reset_index().rename(columns={'index': 'assetKey', 0: 'preOptWeights'})
            dfWeights = dfWeights[[Real.isNonZero(x) for x in dfWeights['preOptWeights']]]



        stratName = rebalConfig.stratName
        rmodel    = SignalMgr.get(rebalConfig.rmodelName, signalDate, stratName)
        tmodel    = SignalMgr.get(rebalConfig.tmodelName, signalDate, stratName).set_index('assetKey')
#        gmult     = Trading.getModelParameters('rrmult', signalDate)
        PyLog.info("Rebalance.py.run SET GMULT=1.0 --FROM YB TRADING GOOGLE SHEET")
        gmult=1.0

        PyLog.info("Rebalance.py.run SET TCAFMULT=1.0 --FROM YB TRADING GOOGLE SHEET")
        tcafmult =1.0
#        tcafmult  = Trading.getModelParameters('tcafmult', signalDate)


        signals = {rebalConfig.alphaName      : 'alpha',
                   rebalConfig.fcostName      : 'fcost',
                   rebalConfig.bcostName      : 'bcost',
                   rebalConfig.longBoundName  : 'longBoundSoft',
                   rebalConfig.shortBoundName : 'shortBoundSoft',
                   'latest_mktcap_usdm'       : 'mktcap',
                   'univwt'                   : 'univwt',
                   'model_country'            : 'modelCountry'}



        dframe = SignalMgr.getFrame(list(signals.keys()), signalDate, stratName).\
            drop(columns=['signalDate']).rename(columns=signals).set_index('assetKey')


        tmult = SignalMgr.get('trading_tcost_amortization_factor_multiplier', signalDate, stratName).iloc[0]
        dct   = {'tcafmult': tcafmult, 'tmult': tmult}

        ## hard position bounds ---------------------------------------------------------------------------------
        dframe = dframe.assign(longBoundHard = 1.0)
        dframe = dframe.assign(shortBoundHard = 1.0)

        dframe = dframe[dframe['univwt'] >= rebalConfig.univwtThreshold]

        dframe = dframe[~dframe['alpha'].isnull()]

        if rebalConfig.tradeUniverseName is not None:
            PyLog.info(f"Getting rebalConfig.tradeUniverseName:{rebalConfig.tradeUniverseName} signalDate:{signalDate} stratName:{stratName}")
            universe = SignalMgr.get(rebalConfig.tradeUniverseName, signalDate, stratName)
            dframe_sz_was = len(dframe)
            dframe = dframe[dframe.index.isin(universe.index)]
            dframe_sz_is = len(dframe)
            PyLog.info(f"dframe filtered from {dframe_sz_was} to {dframe_sz_is}. universe size:{len(universe)}")

        ## drop assets that are excluded from both long and short bounds
        dframe = dframe[~dframe['longBoundSoft'].isnull() | ~dframe['shortBoundSoft'].isnull()]

        # assets = list((set(rmodel['assets']).intersection(set(dframe.index)).intersection(set(tmodel.index))).\
        #     union(set(dfWeights['assetKey'])))
        assets = set(rmodel['assets']).intersection(set(dframe.index))
        assets = assets.intersection(set(tmodel.index))
        assets = sorted(list(assets))
        dframe = dframe.reindex(index=assets).reset_index()

        dframe = dframe.assign(fcost  = dframe['fcost'].fillna(rebalConfig.defaultFCost))
        dframe = dframe.assign(bcost  = dframe['bcost'].fillna(rebalConfig.defaultBCost))

        ## position bounds -------------------------------------------------------------------------------------
        dframe = dframe.assign(longBoundSoft  = dframe['longBoundSoft'].fillna(rebalConfig.defaultLongBound))
        dframe = dframe.assign(shortBoundSoft = dframe['shortBoundSoft'].fillna(rebalConfig.defaultShortBound))


        ## in addition, we want to restrict short to 2% of market cap
        dframe = dframe.assign(shortAvail = dframe['mktcap'].fillna(10.0) * 0.02 * 1000000 / NAV)

        ## cut on min shortAvail in M;- i.e. zero out shortAvail if number is <X

        minShortAvailM = rebalConfig.minShortAvailM
        PyLog.info('DCH ZERO out shortAvail<(${}M)<    // zero shorts for ({}) names out of ({}) which have available shorts'.format(minShortAvailM, len(dframe.loc[dframe.shortAvail<minShortAvailM]) ,len(dframe.loc[dframe.shortAvail>0]) ))
        dframe.loc[dframe.shortAvail < minShortAvailM, 'shortAvail'] = 0

        # dframe = dframe.assign(shortBound = dframe[['shortBound', 'shortAvail']].min(axis=1))
        dframe = dframe.assign(shortBoundHard = dframe[['shortBoundHard', 'shortAvail']].min(axis=1))

        dframe = dframe.drop(columns='shortAvail')

        ## apply liquidity-based position bounds ---------------------------------------------------------------
        if (rebalConfig.maxLiquidityBoundLong is not None) or (rebalConfig.maxLiquidityBoundShort is not None):
            dfm = SignalMgr.getFrame(rebalConfig.liquidityName, signalDate, stratName)
            dframe = dframe.merge(dfm[['assetKey', rebalConfig.liquidityName]], how='left', on='assetKey')
            dframe[rebalConfig.liquidityName] = \
                dframe[rebalConfig.liquidityName].fillna(rebalConfig.defaultLiquidityUSDM)
            if rebalConfig.maxLiquidityBoundLong is not None:
                dframe = dframe.assign(
                    liquidityBoundLong = rebalConfig.maxLiquidityBoundLong * dframe[rebalConfig.liquidityName]
                                         * 1000000 / NAV)
                dframe = dframe.assign(longBoundSoft = dframe[['longBoundSoft', 'liquidityBoundLong']].min(axis=1))
                dframe = dframe.drop(columns=['liquidityBoundLong'])
            if rebalConfig.maxLiquidityBoundShort is not None:
                dframe = dframe.assign(
                    liquidityBoundShort = rebalConfig.maxLiquidityBoundShort * dframe[rebalConfig.liquidityName]
                                          * 1000000 / NAV)
                dframe = dframe.assign(shortBoundSoft = dframe[['shortBoundSoft', 'liquidityBoundShort']].min(axis=1))
                dframe = dframe.drop(columns=['liquidityBoundShort'])
            dframe = dframe.drop(columns=[rebalConfig.liquidityName])
        ## -----------------------------------------------------------------------------------------------------

        ## apply short bans ------------------------------------------------------------------------------------
        if rebalConfig.applyShortBan:
            if PyDate.ge(signalDate, PyMonth.firstWeekday(202003)):
                if PyDate.le(signalDate, PyMonth.firstWeekday(202105)):
                    countries = ['KR', 'ID']
                else:
                    countries = ['ID']
                dframe = dframe.assign(
                    preOptWeights = Filter.bound(-preOptWeights.reindex(dframe['assetKey']).fillna(0.0), lower=0.0))
                dframe.loc[dframe['modelCountry'].isin(countries), 'preOptWeights'] =\
                    dframe.loc[dframe['modelCountry'].isin(countries), 'preOptWeights']
                dframe.loc[~dframe['modelCountry'].isin(countries), 'preOptWeights'] = 1.0
                # dframe = dframe.assign(shortBound = dframe[['shortBound', 'preOptWeights']].min(axis=1))
                dframe = dframe.assign(shortBoundHard = dframe[['shortBoundHard', 'preOptWeights']].min(axis=1))
                dframe = dframe.drop(columns='preOptWeights')
        ## -----------------------------------------------------------------------------------------------------

        # === KR SHORTS ZERO-OUT (no KR shorts) =======================================
        #### KR shorting is not working in the optimizer, we have consistently lost money since the short 
        # ban was lifted in April 2025, so below code disable KR short selling. This is temporary till 
        # we figure out what is the core issue with KR shorts

        """
        # Policy: immediately cover any KR short; forbid new KR shorts. KR longs unaffected.
        try:
            # Detect Korea by modelCountry; also accept RIC suffixes (.KS = KOSPI, .KQ = KOSDAQ) if present
            is_kr = dframe['modelCountry'].astype(str).str.upper().eq('KR')

            if 'ric' in dframe.columns:
                is_kr = is_kr | dframe['ric'].astype(str).str.upper().str.endswith(('.KS', '.KQ'))
            is_kr = is_kr.fillna(False).to_numpy()

            # Asset-level: set shortBoundHard to 0 (no shorts). Keep longBoundHard unchanged.
            sbh = dframe['shortBoundHard'].astype(float).fillna(0.0).to_numpy()
            sbh[is_kr] = 0.0
            dframe['shortBoundHard'] = sbh

            # Per-availability (splitLongShort=True): also clamp line-level short bounds to 0
            if 'assetKey' in dfAvail.columns and 'shortBoundHard' in dfAvail.columns:
                kr_keys = set(dframe.loc[is_kr, 'assetKey'])
                if len(kr_keys) > 0:
                    mask_av = dfAvail['assetKey'].isin(kr_keys)
                    dfAvail.loc[mask_av, 'shortBoundHard'] = 0.0

            PyLog.info(f"KR shorts disabled: set shortBoundHard=0 for {int(is_kr.sum())} KR names (asset + availability).")
        except Exception as e:
            PyLog.info(f"KR shorts disable not applied: {e}")
        # === end KR SHORTS ZERO-OUT ==================================================
        """

        PyLog.info(f"dframe len:{len(dframe)} {dframe.columns.tolist()}")


       ########  Go impose TradeRestrictions pre-optimiser on longBoundHard/ShortBoundHard
        if(tradeRestrictions):
            from .TradeRestrictions  import TradeRestrictions

            added_preOptWeights=False
            if not 'preOptWeights' in dframe.columns:
                preOptWeights_df = pd.DataFrame({'assetKey':preOptWeights.index, 'preOptWeights':preOptWeights.values})
                dframe = dframe.merge(preOptWeights_df, how='left', on='assetKey').fillna(0.0)
                added_preOptWeights=True

            dframe = tradeRestrictions.applyTradeRestrictions( tradeDate, dframe, NAV)

            if added_preOptWeights and 'preOptWeights' in dframe.columns:
                dframe.drop(['preOptWeights'], axis=1, inplace=True)

    #        ###############  Done impose TradeRestrictions ######################################################

        ## dframe is what goes into optimization / dfWeights is for record keeping
        dframe = dframe.merge(dfWeights, how='left', on='assetKey')


        ################ DO NOT TRADE PERISCOPE STOCKS AND ALSO UNWIND EXISTING PERISCOPE STOCKS ####
        periscope_df = EG.read_periscope_current_stocks()

        if periscope_df is None or periscope_df.empty:
            PyLog.info(f"PERISCOPE: no stocks for optimizer for {tradeDate}")
            periscope_keys = set()
        else:
            periscope_keys = set(periscope_df['assetKey'])
            PyLog.info(f"PERISCOPE: {len(periscope_keys)} stocks loaded for optimizer for {tradeDate}")

        is_periscope = dframe['assetKey'].isin(periscope_keys)

        pre = dframe['preOptWeights'].fillna(0.0)
        is_active = abs(pre) > 1e-6

        # longs → force reduce to 0
        dframe.loc[is_periscope & (pre > 0), 'longBoundHard'] = 0.0

        # shorts → force cover to 0
        dframe.loc[is_periscope & (pre < 0), 'shortBoundHard'] = 0.0

        n_long = ((is_periscope) & (pre > 0)).sum()
        n_short = ((is_periscope) & (pre < 0)).sum()

        PyLog.info(f"PERISCOPE UNWIND: {n_long} long, {n_short} short positions forced to unwind for {tradeDate}")


        df_unwind = dframe[is_periscope & is_active][['assetKey', 'preOptWeights', 'longBoundHard', 'shortBoundHard']]

        PyLog.info(f"PERISCOPE UNWIND DETAIL ({len(df_unwind)} rows):")
        PyLog.info(df_unwind)

        # block NEW trades (only for flat positions)
        dframe.loc[is_periscope & (~is_active), 'longBoundHard'] = 0.0
        dframe.loc[is_periscope & (~is_active), 'shortBoundHard'] = 0.0

        n_block = ((is_periscope) & (~is_active)).sum()

        PyLog.info(f"PERISCOPE BLOCK: {n_block} stocks blocked from opening new positions")

        df_block = dframe[is_periscope & (~is_active)][['assetKey', 'preOptWeights', 'longBoundHard', 'shortBoundHard']]

        PyLog.info(f"PERISCOPE BLOCK DETAIL ({len(df_block)} rows):")
        PyLog.info(df_block)

        ###### END OF PERISCOPE UNWIND #######################

        """
        #################   ADD PERISCOPE ALPHA #######################
        PyLog.info(f"Before apply periscope overlay")

        # Periscope overlay (updates dframe hard bounds before any splitLongShort processing)
        if str(getattr(rebalConfig, "periscopeMode", "OFF")).upper() != "OFF":
            applyPeriscopeOverlay(rebalConfig, signalDate, tradeDate, dframe, assets, preOptWeights)


        PyLog.info(f"After apply periscope overlay")

        #################   ADD PERISCOPE ALPHA #######################
        """

        ################ ADD TRBC Activities to be used later in the code ##############
        import model.utils.ric_tracer as rictr
        dframe = rictr.classify_rics_by_trbc(dframe)
        ################ ADD TRBC Activities to be used later in the code ##############


        PyLog.info(f"tradeDate: {tradeDate} rebalConfig.splitLongShort:{rebalConfig.splitLongShort} rebalConfig.splitLongShortStartDate:{rebalConfig.splitLongShortStartDate}")
        ## Note, the time condition here is different from that in Simulation. We want the splitLongShort
        ## applied in optimization first before applying it in the portfolio accounting.
        if rebalConfig.splitLongShort and (tradeDate >= rebalConfig.splitLongShortStartDate):

            dfAvail, dfMarginal = cls.compileShortAvailability(rebalConfig, NAV, tradeDate, dframe)
            #this was allowing optimal starting positions to be constructed exceeding avail borrow. Commenting out.
            #if optimal:
            #    dfMarginal = dfAvail.groupby('assetKey').agg({'shortBoundHard': sum}).reset_index().\
            #        rename(columns={'shortBoundHard': 'availablePct'})
            dframe = dframe.merge(dfMarginal.rename(columns={'availablePct': 'shortBound'}), how='left', on='assetKey')
            dframe = dframe.assign(shortBound=Real.isNegative(dframe['preOptWeights']).astype(int) *
                                              abs(dframe['preOptWeights'].fillna(0.0))
                                              + dframe['shortBound'].fillna(0.0)
                                              + rebalConfig.holdingThreshold / 100.0)
            dframe = dframe.assign(shortBoundHard=dframe[['shortBoundHard', 'shortBound']].min(axis=1))

            """
            ## reflect exchange holidays 
            if rebalConfig.applyExchangeHolidays and (not optimal):
                lstHolidays = cls.getExchangeHolidays(tradeDate)
                dframe = dframe.assign(holiday=dframe['modelCountry'].isin(lstHolidays))
                dframe.loc[dframe['holiday'], 'longBoundHard'] \
                    = Filter.bound(dframe.loc[dframe['holiday'], 'preOptWeights'].fillna(0.0), lower=0.0) \
                      + rebalConfig.holdingThreshold / 100.0
                dframe.loc[dframe['holiday'], 'shortBoundHard'] \
                    = Filter.bound(-dframe.loc[dframe['holiday'], 'preOptWeights'].fillna(0.0), lower=0.0) \
                      + rebalConfig.holdingThreshold / 100.0
            ## reflect exchange holidays 
            """

            if 'holiday' not in dframe.columns:
                dframe = dframe.assign(holiday=False)

            ## NEW reflect exchange holidays 
            if rebalConfig.applyExchangeHolidays and (not optimal):

                try:
                    lstHolidays = cls.getExchangeHolidays(tradeDate)
                    PyLog.info(f"tradeDate = {tradeDate}, lstHolidays={lstHolidays}")

                    dframe = dframe.assign(holiday=dframe['modelCountry'].isin(lstHolidays))

                    PyLog.info("===== HOLIDAY DEBUG: COUNTRY MATCH =====")
                    PyLog.info(f"TradeDate: {tradeDate}")
                    PyLog.info(f"Exchange holidays (lstHolidays): {sorted(list(lstHolidays))}")

                    PyLog.info(
                        "modelCountry counts:\n" +
                        dframe['modelCountry'].value_counts(dropna=False).to_string()
                    )

                    PyLog.info(
                        "Detected holiday assets by modelCountry:\n" +
                        dframe.loc[dframe['holiday'], 'modelCountry']
                              .value_counts()
                              .to_string()
                    )

                    PyLog.info(
                        f"Total holiday assets detected: {int(dframe['holiday'].sum())} / {len(dframe)}"
                    )

                    cur = dframe['preOptWeights'].fillna(0.0)
                    cur_long  = Filter.bound(cur,  lower=0.0)
                    cur_short = Filter.bound(-cur, lower=0.0)

                    dframe.loc[dframe['holiday'], 'longBoundHard']  = cur_long.loc[dframe['holiday']]
                    dframe.loc[dframe['holiday'], 'shortBoundHard'] = cur_short.loc[dframe['holiday']]

                except Exception as e:
                    import traceback
                    PyLog.error(f"Exception inside holiday block: {type(e).__name__}: {e}")
                    PyLog.error(traceback.format_exc())
                    raise

            ## NEW reflect exchange holidays 

            ## NEW reflect exchange holidays in dfAvail also
            dframe = dframe.assign(idx = list(range(len(dframe))))
            dfAvail = dfAvail.merge(dframe[['assetKey', 'idx']], how='left', on='assetKey')

            if rebalConfig.applyExchangeHolidays and (not optimal):

                lk = dframe.set_index('assetKey')

                # update only the holiday rows in dfAvail
                hol_keys = set(dframe.loc[dframe['holiday'], 'assetKey'])

                mask_av_hol = dfAvail['assetKey'].isin(hol_keys)

                if 'longBoundHard' in dframe.columns:

                    dfAvail.loc[mask_av_hol, 'longBoundHard']  = dfAvail.loc[mask_av_hol, 'assetKey'].map(lk['longBoundHard'])

                if 'shortBoundHard' in dframe.columns:
                    dfAvail.loc[mask_av_hol, 'shortBoundHard'] = dfAvail.loc[mask_av_hol, 'assetKey'].map(lk['shortBoundHard'])

                PyLog.info(f"Unique modelCountry values: {dframe['modelCountry'].unique()[:10]}")
                PyLog.info(f"lstHolidays: {lstHolidays}")

                # Debugging: verify holiday logic and propagation 

                num_holidays = dframe['holiday'].sum() if 'holiday' in dframe.columns else 0
                PyLog.info(f"Detected {num_holidays} holiday assets out of {len(dframe)}")

                if num_holidays > 0:
                    PyLog.info("Sample holiday-pinned assets (up to 5 per country):")

                    holidays_df = dframe.loc[dframe['holiday']]

                    for ctry, sub in holidays_df.groupby('modelCountry'):
                        sample = sub[['assetKey']].head(5)
                        PyLog.info(f"\nCountry: {ctry} — {len(sub)} total holiday assets, showing up to 5 assetKeys:\n"
                                   + sample.to_string(index=False))

                    # Cross-check dfAvail bounds match for those same assets
                    merged_check = dfAvail.merge(
                        dframe.loc[dframe['holiday'], ['assetKey', 'longBoundHard', 'shortBoundHard']],
                        how='inner', on='assetKey', suffixes=('_avail', '_dframe')
                    )
                    diff_long = (merged_check['longBoundHard_avail'] - merged_check['longBoundHard_dframe']).abs().sum()
                    diff_short = (merged_check['shortBoundHard_avail'] - merged_check['shortBoundHard_dframe']).abs().sum()
                    PyLog.info(f"Holiday bounds sync check — long diff: {diff_long:.3e}, short diff: {diff_short:.3e}")

            ## NEW reflect exchange holidays in dfAvail also


            """
            ############### High-volatility TRBC Activity hard bounds (unwind only, no new risk) ############
            dframe, dfAvail, activities_found = apply_high_vol_activity_hard_bounds(
                dframe,
                dfAvail=dfAvail,
                activity_col="Activity",
                vol_col="annualized",
                vol_abs_threshold=0.8,  
                tradeDate=tradeDate,
            )
            if activities_found == True:
                n_frozen = (dframe["longBoundHard"] == 0).sum() + (dframe["shortBoundHard"] == 0).sum()
                PyLog.info(f"High-vol Activity freeze: updated hard bounds for {n_frozen} (long/short) entries")
            else:
                PyLog.info("High-vol Activity freeze skipped: no activities above vol threshold")

            ############### High-volatility TRBC Activity hard bounds (unwind only, no new risk) ############
            """

            availAggr = np.zeros((len(dframe), len(dfAvail)))
            for n in range(len(dfAvail)):
                availAggr[dfAvail.iloc[n]['idx'], n] = 1

            factors = rmodel['factors']
            fload = rmodel['fload'].reindex(index=assets, columns=factors)

            ###################### Trend + Vol Conditioning
            PyLog.info("Before Applying trend+vol conditioning ")
            dframe = apply_alpha_conditioning(dframe, signalDate, rebalConfig)
            PyLog.info("After Conditioning complete")
            ###################### Trend + Vol Conditioning

            mu = dframe[['alpha']].to_numpy()
            D = np.diag(rmodel['srisk'].reindex(index=assets) ** 2)

            ## optional off-diagonal risk 
            if rebalConfig.offDiagRisk:
                scov = rmodel['scov']
                scov = scov.merge(dframe[['assetKey', 'idx']].rename(columns={'assetKey': 'xKey', 'idx': 'xIdx'}),
                                  how='inner', on='xKey')
                scov = scov.merge(dframe[['assetKey', 'idx']].rename(columns={'assetKey': 'yKey', 'idx': 'yIdx'}),
                                  how='inner', on='yKey')
                for xIdx, yIdx, cov in zip(scov['xIdx'], scov['yIdx'], scov['cov']):
                    D[xIdx, yIdx] = D[yIdx, xIdx] = cov
            ## optional off-diagonal risk 

            S = rmodel['fcov'].reindex(index=factors, columns=factors).to_numpy()

            if rebalConfig.shrinkfcov:
                S = matrix_shrink(S)
            else:
                PyLog.info(f"rebalConfig.shrinkfcov:{rebalConfig.shrinkfcov} using fcov S as is")
            F = fload.to_numpy()

            w = cp.Variable((len(dfAvail), 1))
            aggrw = availAggr @ w
            wlong = cp.pos(w)
            wshort = cp.neg(w)

            """
            if getattr(rebalConfig, "periscopeMode", "OFF") == "SOFT_PENALTY":
                periscope_pen = buildPeriscopePenalty(rebalConfig, assets, aggrw)
            else:
                periscope_pen = None
            """

            # China Connect short-side stability penalty (only if file exists and scores > 4)
            if rebalConfig.enable_gs_stability_score == True:
                PyLog.info(f"Looking at GS stability files for tradeDate = {tradeDate}")
                china_short_penalty = build_china_connect_short_penalty(dfAvail, wshort, tradeDate)
            else:
                PyLog.info(f"GS Stability flag set to {rebalConfig.enable_gs_stability_score} for tradeDate = {tradeDate}")
                

            f = F.T @ aggrw

            rtau = copy.copy(rebalConfig.tau)
            otau = tmult * rtau
            atau = cp.Parameter(nonneg=True)
            atau.value = max(0, otau - 1)
            btau = cp.Parameter(nonneg=True)
            btau.value = min(1, otau)

            Lmax = cp.Parameter()
            Lmax.value = rebalConfig.maxLeverage
            ## Lmax = rebalConfig.maxLeverage

            ## long financing cost / short borrow cost
            fcost = dfAvail['fcost'].to_numpy() @ wlong
            bcost = dfAvail['bcost'].to_numpy() @ wshort

            ## expected return / risk
            netReturn = mu.T @ aggrw - fcost - bcost

            variance = cp.quad_form(f, S) + cp.quad_form(aggrw, D)

            ## transactions cost
            tmodel = tmodel.reindex(index=assets)
            trade = (aggrw - preOptWeights.reindex(index=assets).fillna(0.0).to_frame().to_numpy())
            tbuy = cp.pos(trade)
            tsell = cp.neg(trade)

            linearCost = tmodel['linearBuyCoeff'].to_numpy() @ tbuy + tmodel['linearSellCoeff'].to_numpy() @ tsell
            impactBuy = cp.multiply(cp.sqrt(NAV) * rebalConfig.impactCostScale,
                                    tmodel['impactCoeffTH'].to_numpy() @ cp.power(tbuy, 3 / 2))
            impactSell = cp.multiply(cp.sqrt(NAV) * rebalConfig.impactCostScale,
                                     tmodel['impactCoeffTH'].to_numpy() @ cp.power(tsell, 3 / 2))
            tcost = linearCost + impactBuy + impactSell

            avgLinearCost = tmodel['linearAverageCoeff'].to_numpy() @ (tbuy + tsell)
            avgTcost = avgLinearCost + impactBuy + impactSell

            ## constraints ------------------------------------------------------------------------------------
            constraints = []
            penalty = 0

            # --- Turnover penalty (soft) -------------------------------------------------
            # trade is already defined above as (aggrw - preOptWeights) or (w - preOptWeights)
            # lambda 0.01 means I need 30 bps of annualized alpha to justify 30% NAV of turnover
            # because lambda * turnover = 0.01 * 0.3, so lambda = 0.003 = 30bps and rhs of 
            # equation is net annualized alpha (mu), so mu = 0.003 = 30bps
            turn_lambda = getattr(rebalConfig, "turnoverPenaltyLambda", 0.01)  
            if turn_lambda > 0:
                # L1 turnover in *weights* (sum abs(delta_w)); dimensionless
                penalty = penalty + turn_lambda * cp.sum(cp.abs(trade))
                PyLog.info(f"Turnover penalty enabled: lambda={turn_lambda}")
            # -----------------------------------------------------------------------------

            """
            if periscope_pen is not None:
                penalty = penalty + periscope_pen
                PyLog.info("Periscope alpha: SOFT_PENALTY added to objective")
            """

            ## Freeze holiday assets: no trades on exchange holidays
            if rebalConfig.applyExchangeHolidays and (not optimal):
                try:
                    if 'holiday' in dframe.columns and dframe['holiday'].any():
                        # preOptWeights in dframe order
                        cur_vec = dframe['preOptWeights'].fillna(0.0).to_numpy().reshape(-1, 1)

                        # indices in aggrw corresponding to holiday assets
                        hol_idx = dframe.loc[dframe['holiday'], 'idx'].to_numpy()

                        if len(hol_idx) > 0:
                            # aggrw[hol_idx] == current weights  →  zero trades for those names
                            constraints.append(aggrw[hol_idx] == cur_vec[hol_idx])
                            PyLog.info(f"Holiday freeze: pinned {len(hol_idx)} assets to preOptWeights.")
                except Exception as e:
                    import traceback
                    PyLog.error(f"Exception inside holiday-freeze block: {type(e).__name__}: {e}")
                    PyLog.error(traceback.format_exc())
                    raise

            # Start with China Connect short penalty if present
            if rebalConfig.enable_gs_stability_score == True:
                PyLog.info("Adding china short penalty")
                if china_short_penalty is not None:
                    penalty = penalty + china_short_penalty

            ## max leverage
            # constraints.append(cp.norm(w, 1) <= Lmax)
            # pen = cp.pos(cp.norm(w, 1) - Lmax - 0.1)
            pen = cp.pos(cp.norm(w, 1) - Lmax)
            penalty = penalty + pen

            ## min leverage
            # if rebalConfig.minLeverage is not None:
            #     Lmin = cp.Parameter()
            #     Lmin.value = rebalConfig.minLeverage
            #     constraints.append(cp.norm(w, 1) >= Lmin)

            ## risk bound
            # constraints.append(variance <= (rebalConfig.maxRisk ** 2))
            pen = 5 * cp.pos(variance - (rebalConfig.maxRisk ** 2))
            penalty = penalty + pen

            ## net exposure bounds
            # constraints.append(cp.sum(w) >= rebalConfig.minNetExposure)
            # constraints.append(cp.sum(w) <= rebalConfig.maxNetExposure)
            pen = cp.pos(cp.sum(aggrw) - rebalConfig.maxNetExposure) \
                  + cp.pos(rebalConfig.minNetExposure - cp.sum(aggrw))
            penalty = penalty + pen

            ## individual position bounds - hard ---------------------------------------------------------
            constraints.append(cp.max(availAggr @ wlong - dframe[['longBoundHard']].to_numpy()) <= 0.0)
            constraints.append(cp.max(availAggr @ wshort - dframe[['shortBoundHard']].to_numpy()) <= 0.0)



            constraints.append(cp.max(wlong - dfAvail[['longBoundHard']].to_numpy()) <= 0.0)
            constraints.append(cp.max(wshort - dfAvail[['shortBoundHard']].to_numpy()) <= 0.0)


            ## individual position bounds - soft ---------------------------------------------------------
            pen = cp.sum(cp.pos(cp.pos(aggrw) - dframe[['longBoundSoft']].to_numpy())) \
                  + cp.sum(cp.pos(cp.neg(aggrw) - dframe[['shortBoundSoft']].to_numpy()))
            penalty = penalty + pen

            ## apply combined position bounds ===========================================================
            if rebalConfig.offDiagRisk:
                # if rebalConfig.applyExchangeHolidays and not 'HK' in lstHolidays:
                combAggr = np.zeros((len(scov), len(assets)))
                for n in range(len(scov)):
                    combAggr[n, scov['xIdx'].iloc[n]] = combAggr[n, scov['yIdx'].iloc[n]] = 1
                dfm = scov.merge(dframe[['assetKey', 'longBoundSoft', 'shortBoundSoft']].
                                 rename(
                    columns={'assetKey': 'xKey', 'longBoundSoft': 'xLong', 'shortBoundSoft': 'xShort'}),
                                 how='left', on='xKey')
                dfm = dfm.merge(dframe[['assetKey', 'longBoundSoft', 'shortBoundSoft']].
                                rename(
                    columns={'assetKey': 'yKey', 'longBoundSoft': 'yLong', 'shortBoundSoft': 'yShort'}),
                                how='left', on='yKey')
                dfm = dfm.assign(longBoundSoft=1.05 * dfm[['xLong', 'yLong']].max(axis=1))
                dfm = dfm.assign(shortBoundSoft=1.05 * dfm[['xShort', 'yShort']].max(axis=1))
                # dfm = dfm.assign(grossBound = 1.5 * dfm[['xLong', 'yLong', 'xShort', 'yShort']].max(axis=1))
                if len(dfm) > 0:
                    # constraints.append(cp.max(cp.pos(aggr @ w) - dfm[['longBound']].to_numpy()) <= 0.0)
                    # constraints.append(cp.max(cp.neg(aggr @ w) - dfm[['shortBound']].to_numpy()) <= 0.0)
                    pen = cp.sum(cp.pos(cp.pos(combAggr @ aggrw) - dfm[['longBoundSoft']].to_numpy())) \
                          + cp.sum(cp.pos(cp.neg(combAggr @ aggrw) - dfm[['shortBoundSoft']].to_numpy()))
                    penalty = penalty + pen
            ## ==========================================================================================

            ## country net/gross exposures
            if not Strategy.isSingleCountry(stratName):
                cBounds = SignalMgr.get(rebalConfig.countryBoundName, signalDate, stratName)
                # if rebalConfig.applyExchangeHolidays:
                #     cBounds = cBounds[~cBounds['cfactor'].isin(['country_' + x for x in lstHolidays])]
                cfactors = [x for x in factors if x.startswith('country_') and x in cBounds['cfactor'].tolist()]
                cBounds = cBounds.set_index('cfactor')
                cBounds = cBounds.reindex(index=cfactors)
                cBounds = cBounds.assign(maxGross=cBounds['maxGross'].fillna(0.0))
                cBounds = cBounds.assign(minNet=cBounds['minNet'].fillna(0.0))
                cBounds = cBounds.assign(maxNet=cBounds['maxNet'].fillna(0.0))
                if rebalConfig.tightCountry:
                    PyLog.info(f"tightening country bounds rebalConfig.tightCountry: {rebalConfig.tightCountry} stratName:{stratName}")
                    cBounds['minNet'] = -0.0
                    cBounds['maxNet'] = 0.0
                #PyLog.info(f"these cBounds are\n{cBounds} total max gross {cBounds.maxGross.sum() :.4f} ")
                # china hack. lower china leave room for JP, TH

                cBounds.loc["country_CN", "maxGross"] = 0.34
                cBounds.loc["country_JP", "maxGross"] = 0.40

                # change cbounds for thanilan to 2.5% of gross
                #cBounds.loc["country_TH", "maxGross"] = 0.16
                cBounds.loc["country_TH", "maxGross"] = 0.025

                #PyLog.info(f"after setting china these cBounds are\n{cBounds} total max gross {cBounds.maxGross.sum() :.4f} ")
                cNetExp = fload.reindex(index=assets, columns=cfactors).to_numpy().T @ aggrw
                maxNet = cBounds[['maxNet']].to_numpy().reshape(cNetExp.shape)
                minNet = cBounds[['minNet']].to_numpy().reshape(cNetExp.shape)
                #constraints.append(cp.max(cNetExp - maxNet) <= 0.0)
                #constraints.append(cp.min(cNetExp - minNet) >= 0.0)
                if rebalConfig.tightCountry:
                    K = 50
                    PyLog.info(f"tightening country bounds with high penalty {K} rebalConfig.tightCountry: {rebalConfig.tightCountry} stratName:{stratName}")
                    pen = K * cp.sum(cp.pos(cNetExp - maxNet)) + K * cp.sum(cp.pos(minNet - cNetExp))
                else:
                    pen = cp.sum(cp.pos(cNetExp - maxNet)) + cp.sum(cp.pos(minNet - cNetExp))
                penalty = penalty + pen
                cGrossExp = fload.reindex(index=assets, columns=cfactors).to_numpy().T @ cp.abs(aggrw)
                # maxGross = (rebalConfig.maxLeverage * 1.05 * cBounds[['maxGross']]).to_numpy().reshape(cGrossExp.shape)
                maxGross = (rebalConfig.maxLeverage * cBounds[['maxGross']]).to_numpy().reshape(cGrossExp.shape)
                # constraints.append(cp.max(cGrossExp - maxGross) <= 0.0)
                pen = cp.sum(cp.pos(cGrossExp - maxGross))
                penalty = penalty + pen

            ## industry net/gross exposures
            iBounds = SignalMgr.get(rebalConfig.industryBoundName, signalDate, stratName)
            #PyLog.info(f"iBounds are: {iBounds}")
            ifactors = [x for x in factors if x.startswith('ind_') if x in iBounds['ifactor'].tolist()]

            iBounds = iBounds.set_index('ifactor')
            iBounds = iBounds.reindex(index=ifactors)
            iBounds = iBounds.assign(maxGross=iBounds['maxGross'].fillna(0.0))
            iBounds = iBounds.assign(minNet=iBounds['minNet'].fillna(0.0))
            iBounds = iBounds.assign(maxNet=iBounds['maxNet'].fillna(0.0))

            iNetExp = fload.reindex(index=assets, columns=ifactors).to_numpy().T @ aggrw
            maxNet = iBounds[['maxNet']].to_numpy().reshape(iNetExp.shape)
            minNet = iBounds[['minNet']].to_numpy().reshape(iNetExp.shape)
            # constraints.append(cp.max(iNetExp - maxNet) <= 0.0)
            # constraints.append(cp.min(iNetExp - minNet) >= 0.0)
            pen = cp.sum(cp.pos(iNetExp - maxNet)) + cp.sum(cp.pos(minNet - iNetExp))
            penalty = penalty + pen

            iGrossExp = fload.reindex(index=assets, columns=ifactors).to_numpy().T @ cp.abs(aggrw)
            # maxGross = (rebalConfig.maxLeverage * 1.05 * iBounds[['maxGross']]).to_numpy().reshape(iGrossExp.shape)
            maxGross = (rebalConfig.maxLeverage * iBounds[['maxGross']]).to_numpy().reshape(iGrossExp.shape)
            # constraints.append(cp.max(iGrossExp - maxGross) <= 0.0)
            pen = cp.sum(cp.pos(iGrossExp - maxGross))
            penalty = penalty + pen


            ## size net/gross exposures
            sfactors = [x for x in factors if x.startswith('size_')]

            sBounds = SignalMgr.get(rebalConfig.sizeBoundName, signalDate, stratName).set_index('sfactor')
            sBounds = sBounds.reindex(index=sfactors)
            sBounds = sBounds.assign(maxGross=sBounds['maxGross'].fillna(0.0))
            sBounds = sBounds.assign(minNet=sBounds['minNet'].fillna(0.0))
            sBounds = sBounds.assign(maxNet=sBounds['maxNet'].fillna(0.0))

            sNetExp = fload.reindex(index=assets, columns=sfactors).to_numpy().T @ aggrw
            maxNet = sBounds[['maxNet']].to_numpy().reshape(sNetExp.shape)
            minNet = sBounds[['minNet']].to_numpy().reshape(sNetExp.shape)
            # constraints.append(cp.max(sNetExp - maxNet) <= 0.0)
            # constraints.append(cp.min(sNetExp - minNet) >= 0.0)
            pen = cp.sum(cp.pos(sNetExp - maxNet)) + cp.sum(cp.pos(minNet - sNetExp))
            penalty = penalty + pen

            sGrossExp = fload.reindex(index=assets, columns=sfactors).to_numpy().T @ cp.abs(aggrw)
            # maxGross = (rebalConfig.maxLeverage * 1.05 * sBounds[['maxGross']]).to_numpy().reshape(sGrossExp.shape)
            maxGross = (rebalConfig.maxLeverage * sBounds[['maxGross']]).to_numpy().reshape(sGrossExp.shape)
            sbounds_maxGross = sBounds[['maxGross']]
            PyLog.info(f"maxGross: {maxGross} = rebalConfig.maxLeverage {rebalConfig.maxLeverage} * {sbounds_maxGross}")
            # constraints.append(cp.max(sGrossExp - maxGross) <= 0.0)
            pen = cp.sum(cp.pos(sGrossExp - maxGross))
            penalty = penalty + pen

            ## net exposure to sbeta_market
            mNetExp = fload.reindex(index=assets, columns=['sbeta_market']).to_numpy().T @ aggrw
            constraints.append(cp.min(mNetExp) >= rebalConfig.minBetaAdjNet)
            constraints.append(cp.max(mNetExp) <= rebalConfig.maxBetaAdjNet)
            PyLog.info(f"mNetExp: {mNetExp} rebalConfig.minBetaAdjNet:{rebalConfig.minBetaAdjNet} rebalConfig.maxBetaAdjNet:{rebalConfig.maxBetaAdjNet}")
            ## phase out value_liq in 2017~ ============================================================
            # if PyDate.ge(signalDate, 20170101) and PyDate.le(signalDate, PyMonth.lastWeekday(202012)):
            # # if PyDate.ge(signalDate, 20170101):
            #     vliqExp = fload.reindex(index=assets, columns=['value_liq']).to_numpy().T @ w
            #     bound = 2.0 * Filter.bound(PyDate.span(signalDate, 20170331) / 90, lower=0.01)
            #     constraints.append(cp.max(vliqExp) <= bound)
            #     constraints.append(cp.min(vliqExp) >= -bound)
            ## ==========================================================================================

#
            mfm = SignalMgr.getStatic('model_universe_frame')
            mfm = mfm.rename(columns={'RkdTicker': 'ticker', 'RkdDisplayRIC': 'ric', 'Country': 'quoteCountry'})
            dframe = mfm[['assetKey', 'ticker', 'quoteCountry', 'ric']].merge(dframe, how='right', on='assetKey')

                 
            """
            df_lbs_sbs_ne0 = dframe[ (dframe['longBoundSoft'] !=0 ) | (dframe['shortBoundSoft'] !=0 ) ]
            univ_wt0 = dframe[ dframe['univwt']!=0 ]
            preOptWeights0 = dframe[ (dframe['preOptWeights']==0) | ( dframe['preOptWeights'].isnull() ) ]
            sb_lb_0 = dframe[ (dframe['longBoundHard'] !=0 ) | (dframe['shortBoundHard'] !=0 ) ]
#            sb_lb_0 = dframe

            user = os.environ['USER']
            sb_lb_0.to_csv(f"/home/{user}/scratch/nonZeroHardBounds.csv", index=False, columns=['assetKey','ticker','quoteCountry','ric']) 
            dframe.to_csv(f"/home/{user}/scratch/preOptUniv.csv", index=False, columns=['assetKey','ticker','quoteCountry','ric']) 
            """
            

            ## apply alpha theme exposure bounds ========================================================
            PyLog.info(f"Theme bound name is {rebalConfig.themeBoundName} for {signalDate}")
            if rebalConfig.themeBoundName is not None:

                themeBounds = SignalMgr.get(rebalConfig.themeBoundName, signalDate, stratName)
                themeBounds = FrameUtil.toSeries(themeBounds, keyCol='factor', valCol='bound') * rebalConfig.maxLeverage
                themes = list(themeBounds.index)
                themeExp = fload.reindex(index=assets, columns=themes).to_numpy().T @ aggrw
                # constraints.append(cp.max(themeExp - themeBounds.to_numpy().reshape(themeExp.shape)) <= 0)
                # constraints.append(cp.min(themeExp + themeBounds.to_numpy().reshape(themeExp.shape)) >= 0)
                pen = cp.sum(cp.pos(themeExp - themeBounds.to_numpy().reshape(themeExp.shape))) \
                      + cp.sum(cp.pos(- themeExp - themeBounds.to_numpy().reshape(themeExp.shape)))
                penalty = penalty + pen
            ## here we are constructing symmetric bounds around 0 but it is the upper bound that we are
            ## primarily concerned about
            ## ==========================================================================================

            ## penalty - apply max trade constraints ====================================================
            if (not optimal) and (rebalConfig.maxAdvProp is not None):
                dfm = SignalMgr.getFrame(rebalConfig.advName, signalDate, stratName)
                dfm = dframe[['assetKey']].merge(dfm[['assetKey', rebalConfig.advName]], how='left', on='assetKey')
                dfm = dfm.assign(
                    maxTradeWeights=rebalConfig.maxAdvProp * dfm[rebalConfig.advName].fillna(0.0) * 1000000 / NAV)
                ## may need to relax this condition for certain conditions (i.e. forced trading)
                # constraints.append(cp.max(cp.abs(trade) - dfm[['maxTradeWeights']].to_numpy()) <= 0.0)
                pen = (cp.abs(trade) - dfm[['maxTradeWeights']].to_numpy()) / dfm[['maxTradeWeights']].to_numpy()
                # pen = 4 * cp.max(cp.pos(pen))
                pen = 4 * cp.sum(cp.pos(pen))
                penalty = penalty + pen
            ## ==========================================================================================

            """
            ################ THIS ENTIRE SECTION IS ADDING TRBC ACTIVITY LEVEL PENALTIES BASED ON SPECRET COV

            try:

                trbc_lambda = getattr(rebalConfig, "trbcClusterPenaltyLambda", 0.02)
                PyLog.info(f"TRBC lambda is {trbc_lambda}")

                trbc_mode   = getattr(rebalConfig, "trbcClusterPenalizeBy", "PENALIZE_NET_EXPOSURE")

                trbc_pen, cluster_df = build_trbc_cluster_penalty(
                    dframe=dframe,
                    assets=assets,
                    aggrw=aggrw,
                    tradeDate=tradeDate,
                    gqr_dir=GQR_SPECRET_DIR,
                    corr_threshold=0.7,
                    min_cluster_size=4,
                    lambda_cluster=trbc_lambda,
                    penalize_by=trbc_mode,
                )

                if isinstance(trbc_pen, cp.Expression):
                    penalty = penalty + trbc_pen
                    PyLog.info("TRBC cluster penalty: added to objective")
                else:
                    PyLog.info("TRBC cluster penalty: nothing to add (no clusters / files missing)")
            except Exception as e:
                PyLog.info(f"TRBC cluster penalty: failed to build ({type(e).__name__}: {e}) -> skipping")

            ################ END ADDING TRBC ACTIVITY LEVEL PENALTIES ###############
            """

            gamma = cp.Parameter(nonneg=True)

            # Uncomment this to print final dataframe before optimization starts
            #dframe.to_csv("dframe_final.csv")

            PyLog.info(f"constraints==== {len(constraints)}===")

            if rebalConfig.adaptiveGamma:

                prevGamma = 0.0
                currGamma = 0.0
                prevRisk = 0.0
                currRisk = 0.0
                offset = 1.0

                while Real.isZero(currRisk) or \
                        abs(currRisk - rebalConfig.adaptiveGammaTargetRisk) > rebalConfig.adaptiveGammaTolerance:

                    if Real.isZero(currRisk):
                        rgamma = rebalConfig.gamma
                    elif Real.isZero(prevRisk):
                        rgamma = currGamma + np.sign(currRisk - rebalConfig.adaptiveGammaTargetRisk) * offset
                    elif Real.isPositive(np.sign(currRisk - rebalConfig.adaptiveGammaTargetRisk)
                                         * np.sign(prevRisk - rebalConfig.adaptiveGammaTargetRisk)):
                        rgamma = currGamma + np.sign(currRisk - rebalConfig.adaptiveGammaTargetRisk)
                    else:
                        rgamma = (prevGamma + currGamma) / 2
                        offset = offset / 4

                    gamma.value = gmult * rgamma

                    ## optimization setup
                    if optimal:
                        problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - penalty), constraints)
                    else:
                        problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - btau * tcost - atau * avgTcost
                                                         - penalty), constraints)

                    numIters = cls.solveProblem(signalDate, rebalConfig=rebalConfig, problem=problem, maxIter=maxIter,
                                                cycleIter=cycleIter, solver=solver, verbose=verbose, checkDCP=checkDCP,
                                                optimal=optimal)

                    if w.value is None:
                        PyLog.info(f"Optimizer failed to converge for {signalDate}")
                        w.value = dfAvail[['preOptWeights']].fillna(0.0).to_numpy()

                    

                    weights = pd.Series(aggrw.value.flatten(), index=assets)

                    prevGamma = currGamma
                    prevRisk = currRisk

                    currGamma = rgamma
                    currRisk = RiskModel.computeRisk(rmodel, weights)

            else:

                rgamma = rebalConfig.gamma
                gamma.value = gmult * rgamma

                ## optimization setup
                if optimal:
                    problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - penalty), constraints)
                else:
                    problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - btau * tcost - atau * avgTcost
                                                     - penalty), constraints)

                numIters = cls.solveProblem(signalDate, rebalConfig=rebalConfig, problem=problem, maxIter=maxIter,
                                            cycleIter=cycleIter, solver=solver, verbose=verbose, checkDCP=checkDCP,
                                            optimal=optimal)

                if w.value is None:
                    PyLog.info("   Optimizer failed to converge")
                    w.value = dfAvail[['preOptWeights']].fillna(0.0).to_numpy()

                optimizer_stats = {
                            "gamma": float(gamma.value),
                            "rtau": float(rtau),
                            "tmult": float(tmult),
                            "otau": float(otau),
                            "atau": float(atau.value),
                            "btau": float(btau.value),

                            "objective": float(problem.value),

                            "gross": float(np.sum(np.abs(aggrw.value))),

                            "netReturn": float(netReturn.value),
                            "variance": float(variance.value),
                            "gamma_variance": float((gamma * variance).value),

                            "linearCost": float(linearCost.value),
                            "avgLinearCost": float(avgLinearCost.value),

                            "impactBuy": float(impactBuy.value),
                            "impactSell": float(impactSell.value),

                            "tcost": float(tcost.value),
                            "avgTcost": float(avgTcost.value),

                            "btau_tcost": float((btau * tcost).value),
                            "atau_avgTcost": float((atau * avgTcost).value),

                            "penalty": float(penalty.value),

                            "assets": len(assets),
                            "NAV": float(NAV),
                        }

                """
                try:
                    PyLog.info(f"========== OPTIMIZER OBJECTIVE BREAKDOWN tradeDate {tradeDate}==========")

                    PyLog.info(
                        f"gamma={gamma.value:.4f} "
                        f"rtau={rtau:.4f} "
                        f"tmult={tmult:.4f} "
                        f"otau={otau:.4f} "
                        f"atau={atau.value:.4f} "
                        f"btau={btau.value:.4f}"
                    )

                    PyLog.info( f"objective=" f"{float(problem.value):,.6f}")
                    PyLog.info( f"assets={len(assets)} " f"NAV={NAV:,.0f}")
                    PyLog.info( f"OPT PARAMS: " f"gamma={rebalConfig.gamma} " f"rtau={rtau} " f"tmult={tmult} " f"otau={otau}")
                    PyLog.info(f"gross={float(np.sum(np.abs(aggrw.value))):.4f}")
                    PyLog.info(f"netReturn      = {float(netReturn.value):,.6f}")
                    PyLog.info(f"variance       = {float(variance.value):,.6f}")
                    PyLog.info(f"gamma*variance = {float((gamma*variance).value):,.6f}")

                    PyLog.info(f"linearCost     = {float(linearCost.value):,.6f}")
                    PyLog.info(f"avgLinearCost  = {float(avgLinearCost.value):,.6f}")

                    PyLog.info(f"impactBuy      = {float(impactBuy.value):,.6f}")
                    PyLog.info(f"impactSell     = {float(impactSell.value):,.6f}")

                    PyLog.info(f"tcost          = {float(tcost.value):,.6f}")
                    PyLog.info(f"avgTcost       = {float(avgTcost.value):,.6f}")

                    PyLog.info(f"btau*tcost     = {float((btau*tcost).value):,.6f}")
                    PyLog.info(f"atau*avgTcost  = {float((atau*avgTcost).value):,.6f}")

                    PyLog.info(f"penalty        = {float(penalty.value):,.6f}")

                    PyLog.info("==================================================")

                except Exception as e:
                    PyLog.info(f"objective logging failed: {e}")
                ########################### LOGGING THE MOTHEFUCKER TAU BECAUSE I THInK THAT IS THE PROBLEM
                """

            """
            # Log TRBC cluster exposures and implied penalty
            log_trbc_cluster_exposures( tradeDate, assets, aggrw, cluster_df=cluster_df, gqr_dir=GQR_SPECRET_DIR,
                                        lambda_cluster=trbc_lambda)
            """

            dfAvail = dfAvail.assign(optimalWeights = list(w.value.flatten()))
            dfAvail = dfAvail.assign(postOptWeights = dfAvail['optimalWeights'])
            # dfAvail.loc[abs(dfAvail['postOptWeights']) < rebalConfig.holdingThreshold, 'postOptWeights'] = 0.0

            dframe = dframe.assign(optimalWeights = availAggr @ dfAvail['optimalWeights'])
            dframe = dframe.assign(postOptWeights = availAggr @ dfAvail['postOptWeights'])

            ########## Check if we have trades for holidays
            PyLog.info("===== STEP 1: HOLIDAY AGG TRADE CHECK =====")

            hol = dframe['holiday'].fillna(False)

            agg_trade = (dframe['postOptWeights'].fillna(0.0) - dframe['preOptWeights'].fillna(0.0))
            PyLog.info(f"ABS SUM holiday AGG trade: {abs(agg_trade[hol]).sum():.12e}")

            bad = dframe.loc[hol].assign(trade=agg_trade[hol]).loc[lambda x: x['trade'].abs() > 1e-5]

            PyLog.info(f"Holiday assets with |agg trade|>1e-5: {len(bad)}")

            #### This is informational only because we are pinning postOptWeights = preOptWeights for holiday countries
            if len(bad):
                PyLog.info("Top holiday agg trades:\n" +
                            bad[['assetKey','modelCountry','preOptWeights','postOptWeights','trade']]
                            .head(20).to_string())

            PyLog.info("===== STEP 2: DFAVAIL MULTI-ROW CHECK (HOLIDAYS) =====")
            hol_keys = set(dframe.loc[dframe['holiday'].fillna(False), 'assetKey'])

            cnt = dfAvail[dfAvail['assetKey'].isin(hol_keys)].groupby('assetKey').size()
            multi = cnt[cnt > 1].sort_values(ascending=False)

            PyLog.info(f"Holiday assetKeys with >1 dfAvail rows: {len(multi)}")
            if len(multi):
                PyLog.info("Top multi-row holiday assets:\n" + multi.head(30).to_string())

            ########## Check if we have trades for holidays

            dfWeights = dfWeights.merge(dframe[['assetKey', 'optimalWeights', 'postOptWeights']],
                                        how='outer', on='assetKey')
            dfWeights.loc[abs(dfWeights['postOptWeights'].fillna(0.0)) < rebalConfig.holdingThreshold, 'postOptWeights'] = 0.0
            dfWeights = dfWeights.assign(
                tradeWeights = dfWeights['postOptWeights'].fillna(0.0) - dfWeights['preOptWeights'].fillna(0.0))

            ######### Do important post optimization validation and send email to support team #####
            cls.post_optimization_validation(
                        optimizer_stats,
                        tradeDate=tradeDate,
                        rebalConfig=rebalConfig,
                        dfWeights=dfWeights,
                        hard_fail=False,
                    )

            ######### Do important post optimization validation and send email to support team #####
            # --- Start China penalty post-solve logging ---

            if rebalConfig.enable_gs_stability_score == True:
                if china_short_penalty is not None and hasattr(china_short_penalty, "_china_penalty_info"):
                    try:
                        info = china_short_penalty._china_penalty_info
                        weights_arr = china_short_penalty._china_penalty_weights
                        scale = china_short_penalty._china_scale

                        PyLog.info("ChinaConnect: post-solve penalty details for penalized assets (top 50):")
                        for idx, assetKey, score, pw in info[:50]:
                            if wshort.value is None:
                                short_w = None
                                contrib = None
                            else:
                                short_w = float(wshort.value[idx])
                                contrib = scale * pw * short_w if short_w is not None else None

                            PyLog.info(
                                f"    assetKey={assetKey}, score={score}, penaltyWeight={pw}, "
                                f"shortWeight={(short_w if short_w is not None else 'None')}, "
                                f"penaltyContribution={(contrib if contrib is not None else 'None')}"
                            )

                        if len(info) > 50:
                            PyLog.info(f"    ...and {len(info) - 50} more penalized assets")

                        total_pen = float(china_short_penalty.value) if china_short_penalty.value is not None else None
                        PyLog.info(f"ChinaConnect: total China penalty = {total_pen}")

                    except Exception as e:
                        PyLog.error(f"ChinaConnect: error computing post-solve penalty log: {e}")
            # --- End China penalty post-solve logging ---

            # weights = pd.Series(w.value.flatten(), index=assets)


        ################################## ENTERTING USELESS SPLIT LONG SHORT BLOCK ########################
        ################################## ENTERTING USELESS SPLIT LONG SHORT BLOCK ########################
        ################################## ENTERTING USELESS SPLIT LONG SHORT BLOCK ########################
        else:
            PyLog.info(f"Entering split block. rebalConfig.splitLongShort:{rebalConfig.splitLongShort} rebalConfig.splitLongShortStartDate:{rebalConfig.splitLongShortStartDate}")
            PyLog.info(f"rebalConfig.applyExchangeHolidays:{rebalConfig.applyExchangeHolidays} optimal:{optimal}")

            """
            ## reflect exchange holidays -------------------------------------------------------------------
            if rebalConfig.applyExchangeHolidays and (not optimal):
                lstHolidays = cls.getExchangeHolidays(tradeDate)
                dframe = dframe.assign(holiday=dframe['modelCountry'].isin(lstHolidays))
                dframe.loc[dframe['holiday'], 'longBoundHard'] \
                    = Filter.bound(dframe.loc[dframe['holiday'], 'preOptWeights'].fillna(0.0), lower=0.0) \
                      + rebalConfig.holdingThreshold / 100.0
                dframe.loc[dframe['holiday'], 'shortBoundHard'] \
                    = Filter.bound(-dframe.loc[dframe['holiday'], 'preOptWeights'].fillna(0.0), lower=0.0) \
                      + rebalConfig.holdingThreshold / 100.0
            ## ---------------------------------------------------------------------------------------------
            """
            
            ## NEW reflect exchange holidays
            if rebalConfig.applyExchangeHolidays and (not optimal):
                lstHolidays = cls.getExchangeHolidays(tradeDate)
                dframe = dframe.assign(holiday=dframe['modelCountry'].isin(lstHolidays))

                cur = dframe['preOptWeights'].fillna(0.0)
                cur_long  = Filter.bound(cur,  lower=0.0)
                cur_short = Filter.bound(-cur, lower=0.0)

                dframe.loc[dframe['holiday'], 'longBoundHard']  = cur_long.loc[dframe['holiday']]
                dframe.loc[dframe['holiday'], 'shortBoundHard'] = cur_short.loc[dframe['holiday']]

            factors = rmodel['factors']
            fload  = rmodel['fload'].reindex(index=assets, columns=factors)
            ## NEW reflect exchange holidays

            mu = dframe[['alpha']].to_numpy()
            D  = np.diag(rmodel['srisk'].reindex(index=assets) ** 2)

            ## optional off-diagonal risk ------------------------------------------------------------------------------
            PyLog.info(f"rebalConfig.offDiagRisk:{rebalConfig.offDiagRisk}")
            if rebalConfig.offDiagRisk:
                scov = rmodel['scov']
                dfm = pd.DataFrame({'assetKey': assets, 'idx': range(len(assets))})
                scov = scov.merge(dfm.rename(columns={'assetKey': 'xKey', 'idx': 'xIdx'}), how='inner', on='xKey')
                scov = scov.merge(dfm.rename(columns={'assetKey': 'yKey', 'idx': 'yIdx'}), how='inner', on='yKey')
                for xIdx, yIdx, cov in zip(scov['xIdx'], scov['yIdx'], scov['cov']):
                    D[xIdx, yIdx] = D[yIdx, xIdx] = cov
            ## ---------------------------------------------------------------------------------------------------------

            S  = rmodel['fcov'].reindex(index=factors, columns=factors).to_numpy()
            if rebalConfig.shrinkfcov:
                S = matrix_shrink(S)
            else:
                PyLog.info(f"rebalConfig.shrinkfcov:{rebalConfig.shrinkfcov} using fcov S as is")
            F  = fload.to_numpy()

            w = cp.Variable((len(assets), 1))
            ## wlong  = cp.maximum( w, 0)
            ## wshort = cp.maximum(-w, 0)
            wlong  = cp.pos(w)
            wshort = cp.neg(w)
            
            f = F.T @ w

            rtau = rebalConfig.tau
            otau = tmult * rtau
            atau = cp.Parameter(nonneg=True)
            atau.value = max(0, otau - 1)
            btau = cp.Parameter(nonneg=True)
            btau.value = min(1, otau)

            Lmax = cp.Parameter()
            Lmax.value = rebalConfig.maxLeverage
            ## Lmax = rebalConfig.maxLeverage

            ## long financing cost / short borrow cost
            fcost = dframe['fcost'].to_numpy() @ wlong
            bcost = dframe['bcost'].to_numpy() @ wshort

            ## expected return / risk
            netReturn = mu.T @ w - fcost - bcost
            variance = cp.quad_form(f, S) + cp.quad_form(w, D)

            ## transactions cost
            tmodel = tmodel.reindex(index=assets)
            trade = (w - preOptWeights.reindex(index=assets).fillna(0.0).to_frame().to_numpy())
            tbuy  = cp.pos(trade)
            tsell = cp.neg(trade)

            linearCost = tmodel['linearBuyCoeff'].to_numpy() @ tbuy + tmodel['linearSellCoeff'].to_numpy() @ tsell
            ## impactCost = (tmodel['impactCoeffTH'].to_numpy() * ((NAV * (tbuy + tsell)) ** (3/2))) / NAV
            ## impactCost = tmodel['impactCoeffTH'].to_numpy() * cp.multiply(1/NAV, cp.power(cp.multiply(NAV, tbuy + tsell), 3/2))
            ## impactBuy  = cp.multiply(1 / NAV, tmodel['impactCoeffTH'].to_numpy() * cp.power(cp.multiply(NAV, tbuy),  3 / 2))
            ## impactSell = cp.multiply(1 / NAV, tmodel['impactCoeffTH'].to_numpy() * cp.power(cp.multiply(NAV, tsell), 3 / 2))
            impactBuy  = cp.multiply(cp.sqrt(NAV) * rebalConfig.impactCostScale,
                                     tmodel['impactCoeffTH'].to_numpy() @ cp.power(tbuy,  3 / 2))
            impactSell = cp.multiply(cp.sqrt(NAV) * rebalConfig.impactCostScale,
                                     tmodel['impactCoeffTH'].to_numpy() @ cp.power(tsell, 3 / 2))
            tcost = linearCost + impactBuy + impactSell

            avgLinearCost = tmodel['linearAverageCoeff'].to_numpy() @ (tbuy + tsell)
            avgTcost = avgLinearCost + impactBuy + impactSell


            ## constraints --------------------------------------------------------------------
            constraints = []
            penalty = 0

            ## max leverage
            # constraints.append(cp.norm(w, 1) <= Lmax)
            # pen = cp.pos(cp.norm(w, 1) - Lmax - 0.1)
            pen = cp.pos(cp.norm(w, 1) - Lmax)
            penalty = penalty + pen

            ## min leverage
            # if rebalConfig.minLeverage is not None:
            #     Lmin = cp.Parameter()
            #     Lmin.value = rebalConfig.minLeverage
            #     constraints.append(cp.norm(w, 1) >= Lmin)

            ## risk bound
            # constraints.append(variance <= (rebalConfig.maxRisk ** 2))
            pen = 5 * cp.pos(variance - (rebalConfig.maxRisk ** 2))
            penalty = penalty + pen

            ## net exposure bounds
            # constraints.append(cp.sum(w) >= rebalConfig.minNetExposure)
            # constraints.append(cp.sum(w) <= rebalConfig.maxNetExposure)
            pen = cp.pos(cp.sum(w) - rebalConfig.maxNetExposure) \
                  + cp.pos(rebalConfig.minNetExposure - cp.sum(w))
            penalty = penalty + pen

            ## individual position bounds - hard ---------------------------------------------------------
            constraints.append(cp.max(wlong  - dframe[['longBoundHard']].to_numpy())  <= 0.0)
            constraints.append(cp.max(wshort - dframe[['shortBoundHard']].to_numpy()) <= 0.0)

            ## individual position bounds - soft ---------------------------------------------------------
            pen = cp.sum(cp.pos(cp.pos(w) - dframe[['longBoundSoft']].to_numpy())) \
                  + cp.sum(cp.pos(cp.neg(w) - dframe[['shortBoundSoft']].to_numpy()))
            penalty = penalty + pen

            ## apply combined position bounds ===========================================================
            if rebalConfig.offDiagRisk:
                # if rebalConfig.applyExchangeHolidays and not 'HK' in lstHolidays:
                    aggr = np.zeros((len(scov), len(assets)))
                    for n in range(len(scov)):
                        aggr[n, scov['xIdx'].iloc[n]] = aggr[n, scov['yIdx'].iloc[n]] = 1
                    dfm = scov.merge(dframe[['assetKey', 'longBoundSoft', 'shortBoundSoft']].
                                     rename(columns={'assetKey': 'xKey', 'longBoundSoft': 'xLong', 'shortBoundSoft': 'xShort'}),
                                     how='left', on='xKey')
                    dfm = dfm.merge(dframe[['assetKey', 'longBoundSoft', 'shortBoundSoft']].
                                    rename(columns={'assetKey': 'yKey', 'longBoundSoft': 'yLong', 'shortBoundSoft': 'yShort'}),
                                    how='left', on='yKey')
                    dfm = dfm.assign(longBoundSoft = 1.05 * dfm[['xLong', 'yLong']].max(axis=1))
                    dfm = dfm.assign(shortBoundSoft = 1.05 * dfm[['xShort', 'yShort']].max(axis=1))
                    # dfm = dfm.assign(grossBound = 1.5 * dfm[['xLong', 'yLong', 'xShort', 'yShort']].max(axis=1))
                    if len(dfm) > 0:
                        # constraints.append(cp.max(cp.pos(aggr @ w) - dfm[['longBound']].to_numpy()) <= 0.0)
                        # constraints.append(cp.max(cp.neg(aggr @ w) - dfm[['shortBound']].to_numpy()) <= 0.0)
                        pen = cp.sum(cp.pos(cp.pos(aggr @ w) - dfm[['longBoundSoft']].to_numpy())) \
                              + cp.sum(cp.pos(cp.neg(aggr @ w) - dfm[['shortBoundSoft']].to_numpy()))
                        penalty = penalty + pen
            ## ==========================================================================================

            ## country net/gross exposures
            if not Strategy.isSingleCountry(stratName):
                cBounds  = SignalMgr.get(rebalConfig.countryBoundName, signalDate, stratName)
                # if rebalConfig.applyExchangeHolidays:
                #     cBounds = cBounds[~cBounds['cfactor'].isin(['country_' + x for x in lstHolidays])]
                cfactors = [x for x in factors if x.startswith('country_') and x in cBounds['cfactor'].tolist()]
                cBounds  = cBounds.set_index('cfactor')
                cBounds  = cBounds.reindex(index=cfactors)
                cBounds  = cBounds.assign(maxGross = cBounds['maxGross'].fillna(0.0))
                cBounds  = cBounds.assign(minNet = cBounds['minNet'].fillna(0.0))
                cBounds  = cBounds.assign(maxNet = cBounds['maxNet'].fillna(0.0))
                if rebalConfig.tightCountry:
                    PyLog.info(f"tightening country bounds rebalConfig.tightCountry: {rebalConfig.tightCountry} stratName:{stratName}")
                    cBounds['minNet'] = -0.0
                    cBounds['maxNet'] = 0.0
                # aggrw is  w
                cNetExp = fload.reindex(index=assets, columns=cfactors).to_numpy().T @ w 
                maxNet = cBounds[['maxNet']].to_numpy().reshape(cNetExp.shape)
                minNet = cBounds[['minNet']].to_numpy().reshape(cNetExp.shape)
                PyLog.info(f"here cBounds are\n{cBounds}")
                #constraints.append(cp.max(cNetExp - maxNet) <= 0.0)
                #constraints.append(cp.min(cNetExp - minNet) >= 0.0)
                if rebalConfig.tightCountry:
                    K = 50
                    PyLog.info(f"tightening country bounds with high penalty {K} rebalConfig.tightCountry: {rebalConfig.tightCountry} stratName:{stratName}")
                    pen = K * cp.sum(cp.pos(cNetExp - maxNet)) + K * cp.sum(cp.pos(minNet - cNetExp))
                else:
                    pen = cp.sum(cp.pos(cNetExp - maxNet)) + cp.sum(cp.pos(minNet - cNetExp))
                penalty = penalty + pen
                cGrossExp = fload.reindex(index=assets, columns=cfactors).to_numpy().T @ cp.abs(w)
                # maxGross = (rebalConfig.maxLeverage * 1.05 * cBounds[['maxGross']]).to_numpy().reshape(cGrossExp.shape)
                maxGross = (rebalConfig.maxLeverage * cBounds[['maxGross']]).to_numpy().reshape(cGrossExp.shape)
                # constraints.append(cp.max(cGrossExp - maxGross) <= 0.0)
                pen = cp.sum(cp.pos(cGrossExp - maxGross))
                penalty = penalty + pen

            ## industry net/gross exposures
            iBounds  = SignalMgr.get(rebalConfig.industryBoundName, signalDate, stratName)

            ifactors = [x for x in factors if x.startswith('ind_') if x in iBounds['ifactor'].tolist()]

            iBounds  = iBounds.set_index('ifactor')
            iBounds  = iBounds.reindex(index=ifactors)
            iBounds  = iBounds.assign(maxGross = iBounds['maxGross'].fillna(0.0))
            iBounds  = iBounds.assign(minNet = iBounds['minNet'].fillna(0.0))
            iBounds  = iBounds.assign(maxNet = iBounds['maxNet'].fillna(0.0))

            iNetExp  = fload.reindex(index=assets, columns=ifactors).to_numpy().T @ w
            maxNet = iBounds[['maxNet']].to_numpy().reshape(iNetExp.shape)
            minNet = iBounds[['minNet']].to_numpy().reshape(iNetExp.shape)
            # constraints.append(cp.max(iNetExp - maxNet) <= 0.0)
            # constraints.append(cp.min(iNetExp - minNet) >= 0.0)
            pen = cp.sum(cp.pos(iNetExp - maxNet)) + cp.sum(cp.pos(minNet - iNetExp))
            penalty = penalty + pen

            iGrossExp = fload.reindex(index=assets, columns=ifactors).to_numpy().T @ cp.abs(w)
            # maxGross = (rebalConfig.maxLeverage * 1.05 * iBounds[['maxGross']]).to_numpy().reshape(iGrossExp.shape)
            maxGross = (rebalConfig.maxLeverage * iBounds[['maxGross']]).to_numpy().reshape(iGrossExp.shape)
            # constraints.append(cp.max(iGrossExp - maxGross) <= 0.0)
            pen = cp.sum(cp.pos(iGrossExp - maxGross))
            penalty = penalty + pen

            ## size net/gross exposures
            sfactors = [x for x in factors if x.startswith('size_')]

            sBounds  = SignalMgr.get(rebalConfig.sizeBoundName, signalDate, stratName).set_index('sfactor')
            sBounds  = sBounds.reindex(index=sfactors)
            sBounds  = sBounds.assign(maxGross = sBounds['maxGross'].fillna(0.0))
            sBounds  = sBounds.assign(minNet = sBounds['minNet'].fillna(0.0))
            sBounds  = sBounds.assign(maxNet = sBounds['maxNet'].fillna(0.0))

            sNetExp  = fload.reindex(index=assets, columns=sfactors).to_numpy().T @ w
            maxNet = sBounds[['maxNet']].to_numpy().reshape(sNetExp.shape)
            minNet = sBounds[['minNet']].to_numpy().reshape(sNetExp.shape)
            # constraints.append(cp.max(sNetExp - maxNet) <= 0.0)
            # constraints.append(cp.min(sNetExp - minNet) >= 0.0)
            pen = cp.sum(cp.pos(sNetExp - maxNet)) + cp.sum(cp.pos(minNet - sNetExp))
            penalty = penalty + pen

            PyLog.info(f"sfactors: {sfactors}")
            PyLog.info(f"fload:\n{fload[sfactors].iloc[0]}")
            sGrossExp = fload.reindex(index=assets, columns=sfactors).to_numpy().T @ cp.abs(w)
            # maxGross = (rebalConfig.maxLeverage * 1.05 * sBounds[['maxGross']]).to_numpy().reshape(sGrossExp.shape)
            maxGross = (rebalConfig.maxLeverage * sBounds[['maxGross']]).to_numpy().reshape(sGrossExp.shape)
            PyLog.info(f"sBounds are\n{sBounds}")
            sbounds_maxGross = sBounds[['maxGross']]
            PyLog.info(f"maxGross: {maxGross} = rebalConfig.maxLeverage {rebalConfig.maxLeverage} * {sbounds_maxGross}")
            # constraints.append(cp.max(sGrossExp - maxGross) <= 0.0)
            pen = cp.sum(cp.pos(sGrossExp - maxGross))
            penalty = penalty + pen


            ## net exposure to sbeta_market
            mNetExp = fload.reindex(index=assets, columns=['sbeta_market']).to_numpy().T @ w
            constraints.append(cp.min(mNetExp) >= rebalConfig.minBetaAdjNet)
            constraints.append(cp.max(mNetExp) <= rebalConfig.maxBetaAdjNet)
            PyLog.info(f"mNetExp: {mNetExp} rebalConfig.minBetaAdjNet:{rebalConfig.minBetaAdjNet} rebalConfig.maxBetaAdjNet:{rebalConfig.maxBetaAdjNet}")


            ## phase out value_liq in 2017~ ============================================================
            # if PyDate.ge(signalDate, 20170101) and PyDate.le(signalDate, PyMonth.lastWeekday(202012)):
            # # if PyDate.ge(signalDate, 20170101):
            #     vliqExp = fload.reindex(index=assets, columns=['value_liq']).to_numpy().T @ w
            #     bound = 2.0 * Filter.bound(PyDate.span(signalDate, 20170331) / 90, lower=0.01)
            #     constraints.append(cp.max(vliqExp) <= bound)
            #     constraints.append(cp.min(vliqExp) >= -bound)
            ## ==========================================================================================

            ## apply alpha theme exposure bounds ========================================================
            if rebalConfig.themeBoundName is not None:
                themeBounds = SignalMgr.get(rebalConfig.themeBoundName, signalDate, stratName)
                themeBounds = FrameUtil.toSeries(themeBounds, keyCol='factor', valCol='bound') * rebalConfig.maxLeverage
                themes = list(themeBounds.index)
                themeExp = fload.reindex(index=assets, columns=themes).to_numpy().T @ w
                # constraints.append(cp.max(themeExp - themeBounds.to_numpy().reshape(themeExp.shape)) <= 0)
                # constraints.append(cp.min(themeExp + themeBounds.to_numpy().reshape(themeExp.shape)) >= 0)
                pen = cp.sum(cp.pos(themeExp - themeBounds.to_numpy().reshape(themeExp.shape))) \
                    + cp.sum(cp.pos(- themeExp - themeBounds.to_numpy().reshape(themeExp.shape)))
                penalty = penalty + pen
            ## here we are constructing symmetric bounds around 0 but it is the upper bound that we are
            ## primarily concerned about
            ## ==========================================================================================

            ## penalty - apply max trade constraints ====================================================
            if (not optimal) and (rebalConfig.maxAdvProp is not None):
                dfm = SignalMgr.getFrame(rebalConfig.advName, signalDate, stratName)
                dfm = dframe[['assetKey']].merge(dfm[['assetKey', rebalConfig.advName]], how='left', on='assetKey')
                dfm = dfm.assign(
                    maxTradeWeights = rebalConfig.maxAdvProp * dfm[rebalConfig.advName].fillna(0.0) * 1000000 / NAV)
                ## may need to relax this condition for certain conditions (i.e. forced trading)
                # constraints.append(cp.max(cp.abs(trade) - dfm[['maxTradeWeights']].to_numpy()) <= 0.0)
                pen = (cp.abs(trade) - dfm[['maxTradeWeights']].to_numpy()) / dfm[['maxTradeWeights']].to_numpy()
                # pen = 4 * cp.max(cp.pos(pen))
                pen = 4 * cp.sum(cp.pos(pen))
                penalty = penalty + pen
            ## ==========================================================================================

            gamma = cp.Parameter(nonneg=True)

            if rebalConfig.adaptiveGamma:

                prevGamma = 0.0
                currGamma = 0.0
                prevRisk = 0.0
                currRisk = 0.0
                offset = 1.0

                while Real.isZero(currRisk) or \
                        abs(currRisk - rebalConfig.adaptiveGammaTargetRisk) > rebalConfig.adaptiveGammaTolerance:

                    if Real.isZero(currRisk):
                        rgamma = rebalConfig.gamma
                    elif Real.isZero(prevRisk):
                        rgamma = currGamma + np.sign(currRisk - rebalConfig.adaptiveGammaTargetRisk) * offset
                    elif Real.isPositive(np.sign(currRisk - rebalConfig.adaptiveGammaTargetRisk)
                                         * np.sign(prevRisk - rebalConfig.adaptiveGammaTargetRisk)):
                        rgamma = currGamma + np.sign(currRisk - rebalConfig.adaptiveGammaTargetRisk)
                    else:
                        rgamma = (prevGamma + currGamma) / 2
                        offset = offset / 4

                    gamma.value = gmult * rgamma

                    ## optimization setup
                    if optimal:
                        problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - penalty), constraints)
                    else:
                        problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - btau * tcost - atau * avgTcost
                                                         - penalty), constraints)

                    numIters = cls.solveProblem(signalDate, rebalConfig=rebalConfig, problem=problem, maxIter=maxIter,
                                                cycleIter=cycleIter, solver=solver, verbose=verbose, checkDCP=checkDCP,
                                                optimal=optimal)


                    if w.value is None:
                        PyLog.info("   Optimizer failed to converge")
                        w.value = dframe[['preOptWeights']].fillna(0.0).to_numpy()

                    weights = pd.Series(w.value.flatten(), index=assets)

                    prevGamma = currGamma
                    prevRisk = currRisk

                    currGamma = rgamma
                    currRisk = RiskModel.computeRisk(rmodel, weights)

            else:

                rgamma = rebalConfig.gamma
                gamma.value = gmult * rgamma

                ## optimization setup
                if optimal:
                    problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - penalty), constraints)
                else:
                    problem = cp.Problem(cp.Maximize(netReturn - gamma * variance - btau * tcost - atau * avgTcost
                                                     - penalty), constraints)

                numIters = cls.solveProblem(signalDate, rebalConfig=rebalConfig, problem=problem, maxIter=maxIter,
                                            cycleIter=cycleIter, solver=solver, verbose=verbose, checkDCP=checkDCP,
                                            optimal=optimal)

                if w.value is None:
                    PyLog.info("   Optimizer failed to converge")
                    w.value = dframe[['preOptWeights']].fillna(0.0).to_numpy()

                weights = pd.Series(w.value.flatten(), index=assets)

            dfWeights = dfWeights.merge(weights.reset_index().rename(columns={'index': 'assetKey', 0: 'optimalWeights'}),
                                        how='outer', on='assetKey')

            dfWeights = dfWeights.assign(postOptWeights = dfWeights['optimalWeights'])
            dfWeights.loc[abs(dfWeights['postOptWeights'].fillna(0.0)) < rebalConfig.holdingThreshold, 'postOptWeights'] = 0.0
            # weights.loc[abs(weights) < rebalConfig.holdingThreshold] = 0.0
            # dfWeights = dfWeights.merge(weights.reset_index().rename(columns={'index': 'assetKey', 0: 'postOptWeights'}),
            #                             how='outer', on='assetKey')


        dctOptResult['numIters']  = numIters

        ## including cvxpy objects increased output file from 100K to 80MB
        dctOptResult['gamma']     = rgamma
        dctOptResult['netReturn'] = netReturn.value
        dctOptResult['variance']  = variance.value

        dfWeights = dfWeights.merge(dframe[['assetKey', 'longBoundHard', 'shortBoundHard']], how='left', on='assetKey')
        dfWeights = dfWeights.assign(longBoundHard = dfWeights['longBoundHard'].fillna(0.0))
        dfWeights = dfWeights.assign(shortBoundHard = dfWeights['shortBoundHard'].fillna(0.0))

        dfWeights = dfWeights.assign(preOptWeights  = dfWeights['preOptWeights'].fillna(0.0))
        dfWeights = dfWeights.assign(optimalWeights = dfWeights['optimalWeights'].fillna(0.0))
        dfWeights = dfWeights.assign(postOptWeights = dfWeights['postOptWeights'].fillna(0.0))
        dfWeights = dfWeights.assign(tradeWeights   = dfWeights['postOptWeights'] - dfWeights['preOptWeights'])
        dctOptResult['dfWeights'] = dfWeights

        dframe = dfWeights.merge(tmodel.reset_index(), how='left', on='assetKey')

        linearCost = NAV * ((Filter.bound(dframe['tradeWeights'], lower=0.0) *
                             dframe['linearBuyCoeff'].fillna(dframe['linearBuyCoeff'].max())) -
                            (Filter.bound(dframe['tradeWeights'], upper=0.0) *
                             dframe['linearSellCoeff'].fillna(dframe['linearSellCoeff'].max()))).sum()
        dctOptResult['linearCostUSD'] = linearCost

        impactCost = rebalConfig.impactCostScale * np.power(NAV, 3 / 2) * \
                     (np.power(abs(dframe['tradeWeights']), 3 / 2) *
                      dframe['impactCoeffTH'].fillna(dframe['impactCoeffTH'].max())).sum()
        dctOptResult['impactCostUSD'] = impactCost

        dctOptResult['tcostUSD'] = linearCost + impactCost

        dctOptResult['fcost'] = fcost.value[0]
        dctOptResult['bcost'] = bcost.value[0]
        dctOptResult['tradeWeight'] = abs(dfWeights['tradeWeights']).sum()
        dctOptResult['tradeValUSD'] = dctOptResult['tradeWeight'] * dctOptResult['preOptNAV']

        return dctOptResult

    @classmethod
    def getExchangeHolidays(cls, tradeDate):
        hframe = SignalMgr.getStatic('trading_exchange_holidays_master_frame')
        hframe = hframe[hframe['tradeDate'] == tradeDate]
        hframe = hframe[hframe['trading'] == 'No']
        #lstHolidays = hframe['quoteCountry'].tolist()

        ### Get unique countries since our holidays table/dataframe has exchange holidays
        lstHolidays = list(set(hframe['quoteCountry'].tolist()))

        """
        if 'HK' in hframe['quoteCountry']:
            lstHolidays = list(set(lstHolidays + ['CN', 'XH']))
        """

        if (hframe['quoteCountry'] == 'HK').any():
            lstHolidays = list(set(lstHolidays + ['CN', 'XH']))

        PyLog.info(f"Hols={lstHolidays}")

        return lstHolidays


    @classmethod
    def solveProblem(cls, signalDate, rebalConfig, problem, maxIter, cycleIter,
                                 solver=cp.ECOS, verbose=True, checkDCP=True, optimal=False):

        if checkDCP and (not problem.is_dcp()):
            raise Exception("Optimization failure: problem is not DCP.")

        numIters = 0
        success = True

        # =============================================================================
        # SCS PATH (unchanged, iterative tolerance ladder)
        # =============================================================================
        if solver == cp.SCS:

            PyLog.info("solveProblemWithClarabel: entering SCS path")

            targetTolerance = rebalConfig.targetTolerance
            toleranceRange = rebalConfig.toleranceRange

            nAttempt = 0
            maxAttempt = len(toleranceRange)
            success = False
            auxIter = 10

            while (not success) and (nAttempt < maxAttempt):

                PyLog.info(f"SCS attempt {nAttempt} with targetTolerance={targetTolerance:e}")

                maxCycles = int(np.ceil(maxIter / cycleIter))
                nCycles = 0

                while (not success) and (nCycles < maxCycles):

                    try:
                        val1 = problem.solve(
                            solver=cp.SCS,
                            max_iters=cycleIter,
                            eps=targetTolerance,
                            verbose=verbose
                        )

                        PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")

                    except Exception as ee:
                        PyLog.warning(f"SCS cycle failure: caught {ee}")
                        PyLog.warning(traceback.format_exc())

                    iters = problem.solver_stats.num_iters
                    numIters += iters
                    nCycles += 1

                    if iters < cycleIter:
                        PyLog.info("SCS converged within cycle")
                        success = True

                if not success:
                    try:
                        PyLog.info(f"SCS auxiliary solve with tolerance={toleranceRange[nAttempt]:e}")
                        val2 = problem.solve(
                            solver=cp.SCS,
                            max_iters=auxIter,
                            eps=toleranceRange[nAttempt],
                            verbose=verbose
                        )
                        PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")

                    except Exception as ee:
                        PyLog.warning(f"SCS auxiliary failure: caught {ee}")
                        PyLog.warning(traceback.format_exc())

                    iters = problem.solver_stats.num_iters
                    numIters += iters

                    if iters < auxIter:
                        PyLog.info("SCS converged in auxiliary solve")
                        success = True
                    else:
                        PyLog.warning("SCS did not converge, increasing tolerance")
                        nAttempt += 1

            return numIters

        # =============================================================================
        # ECOS PATH (unchanged, 2 levels + SCS fallback)
        # =============================================================================
        if solver == cp.ECOS:

            PyLog.info("solveProblemWithClarabel: entering ECOS path")

            try:
                PyLog.info(f"ECOS level one solve {signalDate}")
                val1 = problem.solve(
                    solver=cp.ECOS,
                    max_iters=maxIter,
                    warm_start=True,
                    abstol=1e-5,
                    reltol=5e-4,
                    feastol=1e-5,
                    verbose=verbose
                )
                PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")

            except Exception as ee:
                success = False
                PyLog.warning(f"ECOS failure level one tolerance. caught: {ee}")
                PyLog.warning(traceback.format_exc())

            if (not success) or (problem.status == "infeasible"):
                try:
                    PyLog.info("ECOS level two solve (looser tolerances)")
                    success = True
                    val2 = problem.solve(
                        solver=cp.ECOS,
                        max_iters=int(maxIter * 2),
                        warm_start=True,
                        abstol=5e-5,
                        reltol=1e-3,
                        feastol=2e-5,
                        verbose=verbose
                    )
                    PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")
                except Exception as ee:
                    success = False
                    PyLog.warning(f"ECOS failure level two tolerance. caught: {ee}")
                    PyLog.warning(traceback.format_exc())

            if (not success) or (problem.status == "infeasible"):
                try:
                    PyLog.warning("ECOS failed or infeasible, falling back to SCS")
                    val3 = problem.solve(
                        solver=cp.SCS,
                        verbose=verbose
                    )
                    PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")
                except Exception as ee:
                    PyLog.warning(f"SCS failure third level tolerance. caught: {ee}")
                    PyLog.warning(traceback.format_exc())

            return problem.solver_stats.num_iters

        # =============================================================================
        # CLARABEL PATH (new, mirrors ECOS structure)
        # =============================================================================
        if solver == cp.CLARABEL:

            PyLog.info("solveProblemWithClarabel: entering CLARABEL path")

            try:
                PyLog.info(f"CLARABEL level one solve for signal date = {signalDate}")
                try:
                    val1 = problem.solve(
                        solver=cp.CLARABEL,
                        max_iter=maxIter,
                        warm_start=True,
                        tol_feas=1e-5,
                        tol_gap_abs=1e-5,
                        tol_gap_rel=5e-4,
                        verbose=verbose
                    )
                    PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")
                except TypeError:
                    PyLog.info("CLARABEL level one retry without tolerances (compat mode)")
                    val1 = problem.solve(
                        solver=cp.CLARABEL,
                        max_iter=maxIter,
                        warm_start=True,
                        verbose=verbose
                    )
                    PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")
            except Exception as ee:
                success = False
                PyLog.warning(f"CLARABEL failure level one tolerance. caught: {ee}")
                PyLog.warning(traceback.format_exc())

            if (not success) or (problem.status == "infeasible"):
                try:
                    PyLog.info(f"CLARABEL level two solve (looser tolerances) for signal date = {signalDate}")
                    success = True
                    try:
                        val2 = problem.solve(
                            solver=cp.CLARABEL,
                            max_iter=int(maxIter * 2),
                            warm_start=True,
                            tol_feas=2e-5,
                            tol_gap_abs=5e-5,
                            tol_gap_rel=1e-3,
                            verbose=verbose
                        )
                        PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")
                    except TypeError:
                        PyLog.info("CLARABEL level two retry without tolerances (compat mode)")
                        val2 = problem.solve(
                            solver=cp.CLARABEL,
                            max_iter=int(maxIter * 2),
                            warm_start=True,
                            verbose=verbose
                        )
                        PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}for {signalDate}")
                except Exception as ee:
                    success = False
                    PyLog.warning(f"CLARABEL failure level two tolerance. caught: {ee} for {signalDate}")
                    PyLog.warning(traceback.format_exc())

            if (not success) or (problem.status == "infeasible"):
                try:
                    PyLog.warning("CLARABEL failed or infeasible, falling back to SCS")
                    val3 = problem.solve(
                        solver=cp.SCS,
                        verbose=verbose
                    )
                    PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters} for {signalDate}")
                except Exception as ee:
                    PyLog.warning(f"SCS failure third level tolerance. caught: {ee}")
                    PyLog.warning(traceback.format_exc())

            return problem.solver_stats.num_iters

        # =============================================================================
        # UNKNOWN SOLVER → ECOS → SCS FALLBACK
        # =============================================================================
        PyLog.warning(f"Unknown solver {solver}, falling back to ECOS/SCS")

        try:
            PyLog.info(f"Unknown solver fallback: ECOS for signal date = {signalDate}")
            problem.solve(
                solver=cp.ECOS,
                max_iters=maxIter,
                warm_start=True,
                abstol=1e-5,
                reltol=5e-4,
                feastol=1e-5,
                verbose=verbose
            )
            PyLog.info(f"solver={solver} status={problem.status} iters={problem.solver_stats.num_iters}")
        except Exception as ee:
            PyLog.warning(f"ECOS fallback failed. caught: {ee}")
            PyLog.warning(traceback.format_exc())
            try:
                PyLog.warning("Final fallback: SCS")
                problem.solve(solver=cp.SCS, verbose=verbose)
            except Exception as ee2:
                PyLog.warning(f"SCS final fallback failed. caught: {ee2}")
                PyLog.warning(traceback.format_exc())

        return problem.solver_stats.num_iters



    @classmethod
    def post_optimization_validation(
        cls,
        optimizer_stats,
        tradeDate,
        rebalConfig,
        dfWeights,
        hard_fail=False
    ):
        context = "post_opt_validation"

        # =========================
        # File logging setup
        # =========================
        def _trade_date_str(td):
            try:
                return td.strftime("%Y%m%d")
            except Exception:
                return str(td)

        td_str = _trade_date_str(tradeDate)

        logdir = os.environ.get("LOGDIR") or "/data/log"
        out_dir = os.path.join(logdir, "post_opt_results")
        os.makedirs(out_dir, exist_ok=True)

        out_path = os.path.join(out_dir, f"post_opt_{td_str}.txt")

        had_errors = False

        f = open(out_path, "w", encoding="utf-8")

        # placeholder header (rewritten at end)
        f.write(" " * 200 + "\n")
        f.flush()

        def log_info(msg):
            nonlocal had_errors
            PyLog.info(msg)
            f.write(str(msg).rstrip("\n") + "\n")
            f.flush()
            if "FAILED" in str(msg):
                had_errors = True

        def log_optimizer(msg):
            PyLog.info(msg)
            f.write(str(msg).rstrip("\n") + "\n")
            f.flush()

        log_optimizer("==================================================")
        log_optimizer("OPTIMIZER OBJECTIVE BREAKDOWN")
        log_optimizer("==================================================")

        for k, v in optimizer_stats.items():
            log_optimizer(f"{k:20s} = {v}")

        log_optimizer("==================================================")
        log_optimizer("")


        # =========================
        # ORIGINAL FUNCTION LOGIC
        # =========================

        if 'postOptWeights' not in dfWeights.columns or 'preOptWeights' not in dfWeights.columns:
            PyLog.warning(f"[{context}] Missing preOptWeights/postOptWeights; skipping validation for {tradeDate}.")
            f.write(f"[{context}] Missing preOptWeights/postOptWeights; skipping validation for {tradeDate}.\n")
            f.flush()
            f.seek(0)
            f.write(f"ALL IS WELL in {td_str} post optimization\n")
            f.close()
            return True

        post_w = dfWeights['postOptWeights'].fillna(0.0)
        pre_w  = dfWeights['preOptWeights'].fillna(0.0)
        trade_w = post_w - pre_w

        # ---- (1) GROSS CHECK ----
        gross = abs(post_w).sum()
        max_gross = float(getattr(rebalConfig, "maxLeverage", 4.0))
        gross_tol = float(getattr(rebalConfig, "postOptGrossTol", 0.20))
        gross_limit = max_gross + gross_tol

        log_info(
            f"[{context}] {tradeDate} gross={gross:.4f} "
            f"limit={gross_limit:.4f} (maxLeverage={max_gross:.2f} tol={gross_tol:.2f})"
        )

        if gross > gross_limit:
            msg = (
                f"[{context}] FAILED gross_exceeds_limit @ {tradeDate}: "
                f"gross={gross:.4f} > limit={gross_limit:.4f}"
            )
            log_info(msg)
            if hard_fail:
                f.seek(0)
                f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
                f.close()
                raise Exception(msg)
            f.seek(0)
            f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
            f.close()
            return False

        # ---- (2) NET CHECK ----
        net = post_w.sum()
        min_net = float(getattr(rebalConfig, "minPostOptNetExposure", -0.1))
        max_net = float(getattr(rebalConfig, "maxPostOptNetExposure", 0.5))

        log_info(
            f"[{context}] {tradeDate} net={net:.4f} "
            f"bounds=[{min_net:.4f},{max_net:.4f}]"
        )

        if net < min_net or net > max_net:
            msg = (
                f"[{context}] FAILED net_out_of_bounds @ {tradeDate}: "
                f"net={net:.4f} not in [{min_net:.4f},{max_net:.4f}]"
            )
            log_info(msg)
            if hard_fail:
                f.seek(0)
                f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
                f.close()
                raise Exception(msg)
            f.seek(0)
            f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
            f.close()
            return False

        # ---- (3) TURNOVER CHECK ----
        turnover = abs(trade_w).sum()

        if gross > 1e-12:
            turnover_ratio = turnover / gross
            max_turnover_ratio = float(getattr(rebalConfig, "postOptMaxTurnover", 0.20))

            turnover_pct = turnover_ratio * 100.0
            max_turnover_pct = max_turnover_ratio * 100.0

            log_info(
                f"[{context}] {tradeDate} turnover={turnover:.4f} "
                f"turnover_percent={turnover_pct:.2f}% limit={max_turnover_pct:.2f}%"
            )

            if turnover_ratio > max_turnover_ratio:
                msg = (
                    f"[{context}] FAILED turnover={turnover:.4f} turnover exceeds limit for {tradeDate}: "
                    f"turnover_percent={turnover_pct:.2f}% > limit={max_turnover_pct:.2f}"
                )
                log_info(msg)
                if hard_fail:
                    f.seek(0)
                    f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
                    f.close()
                    raise Exception(msg)
                f.seek(0)
                f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
                f.close()
                return False
        else:
            log_info(f"[{context}] {tradeDate} gross≈0 and turnover≈0 (turnover check skipped).")

        # ---- (4) MAX SINGLE NAME ----
        max_abs_name = abs(post_w).max() if len(post_w) else 0.0
        max_name_abs = getattr(rebalConfig, "postOptMaxAbsName", None)

        if max_name_abs is not None:
            log_info(
                f"[{context}] {tradeDate} max|w|={max_abs_name:.4f} "
                f"limit={float(max_name_abs):.4f}"
            )

            if max_abs_name > float(max_name_abs):
                msg = (
                    f"[{context}] FAILED name_concentration_exceeds_limit @ {tradeDate}: "
                    f"max|w|={max_abs_name:.4f}"
                )
                log_info(msg)
                if hard_fail:
                    f.seek(0)
                    f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
                    f.close()
                    raise Exception(msg)
                f.seek(0)
                f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
                f.close()
                return False
        else:
            log_info(f"[{context}] {tradeDate} max|w|={max_abs_name:.4f} (no limit configured)")

        log_info(f"[{context}] PASSED all checks @ {tradeDate}.")

        # =========================
        # Final header write
        # =========================
        f.seek(0)
        if had_errors:
            f.write(f"CRITICAL ERRORS in {td_str} post optimization\n")
        else:
            f.write(f"ALL IS WELL in {td_str} post optimization\n")
        f.close()

        return True
     

    @classmethod
    def OLDsolveProblem(cls, rebalConfig, problem, maxIter=200000, cycleIter=5000,
                     solver=cp.ECOS, verbose=False, checkDCP=False, optimal=False):

        if checkDCP:
            dcpFlag = problem.is_dcp()
            PyLog.info('Problem is DCP (Disciplined Convex Problem): {}'.format(dcpFlag))
            PyLog.assertion(dcpFlag, "Optimization problem is not DCP (Disciplined Convex Problem)")

        if solver == cp.SCS:

            targetTolerance = rebalConfig.targetTolerance
            toleranceRange = rebalConfig.toleranceRange

            nAttempt = 0
            maxAttempt = len(toleranceRange)
            success = False
            auxIter = 10
            numIters = 0

            while (not success) and (nAttempt < maxAttempt):

                maxCycles = int(np.ceil(maxIter / cycleIter))
                nCycles = 0
                cumIters = 0

                while (not success) and (nCycles < maxCycles):

                    tol = targetTolerance
                    try:
                        problem.solve(verbose=verbose, solver=solver, max_iters=cycleIter, eps=tol)
                    except:
                        pass

                    cumIters += problem.solver_stats.num_iters
                    numIters += problem.solver_stats.num_iters
                    nCycles += 1

                    PyLog.info('tau : {:.2f}  /  numIters : {}  / cumIters : {}  /  tol : {:e}'.format(
                        0.0 if optimal else rtau, problem.solver_stats.num_iters, cumIters, tol))

                    if (problem.solver_stats.num_iters < cycleIter):
                        success = True

                if (problem.solver_stats.num_iters < cycleIter):
                    success = True
                else:
                    tol = toleranceRange[nAttempt]
                    try:
                        problem.solve(verbose=verbose, solver=solver, max_iters=auxIter, eps=tol)
                    except:
                        pass

                    PyLog.info('tau : {:.2f}  /  numIters : {}  /  tol : {:e}'.format(
                        0.0 if optimal else rtau, problem.solver_stats.num_iters, tol))

                    numIters += problem.solver_stats.num_iters

                    if (problem.solver_stats.num_iters < auxIter):
                        success = True
                    else:
                        nAttempt += 1

        else:
            try:
                success = True
                PyLog.info(f"level one ECOS solve ")
                #problem.solve(verbose=verbose, solver=cp.ECOS, max_iters=maxIter, abstol=1e-6, reltol=1e-4, feastol=2e-6, abstol_inacc=2e-6, reltol_inacc=2e-4, feastol_inacc=2e-6)
                val1 = problem.solve(verbose=verbose, 
                                     solver=cp.ECOS, 
                                     max_iters=maxIter, 
                                     warm_start=True, 
                                     abstol=1e-5, 
                                     reltol=5e-4, 
                                     feastol=1e-5)

                #CLARABEL : cvxpy deprecated ECOS in May 2024
                #PyLog.info(f"level one CLARABEL solve")
                #problem.solve(verbose=verbose, solver=cp.CLARABEL)

            except Exception as ee:
                success = False
                PyLog.warning(f"Error: optimization failure first level tolerance. td: caught: {ee}")
                PyLog.warning(traceback.format_exc())

            if (not success) or (problem.status == 'infeasible'):
                try:
                    success = True
                    PyLog.info(f"level two ECOS solve ")
                    #problem.solve(verbose=verbose, solver=cp.ECOS, max_iters=maxIter,
                    #              abstol=1e-6, reltol=5e-4, feastol=2e-6,
                    #              abstol_inacc=2e-6, reltol_inacc=1e-3, feastol_inacc=2e-6)
                    # --- Level 2 (fallback, looser than Level 1) ---
                    val2 = problem.solve(
                        solver=cp.ECOS,
                        max_iters=int(maxIter * 2),  # give the fallback extra room
                        warm_start=True,
                        abstol=5e-5,
                        reltol=1e-3,
                        feastol=2e-5,
                        verbose=verbose
                    )

                except Exception as ee:
                    success = False
                    PyLog.warning(f"Error: ECOS failure second level tolerance. td: caught: {ee}")
                    PyLog.warning(traceback.format_exc())

            if (not success) or (problem.status == 'infeasible'):
                try:
                    PyLog.info(f"level three SCS solve ")
                    #problem.solve(verbose=verbose, solver=cp.ECOS, max_iters=maxIter,
                    #              abstol=1e-6, reltol=5e-3, feastol=2e-6,
                    #              abstol_inacc=2e-6, reltol_inacc=1e-2, feastol_inacc=2e-6)
                    problem.solve(verbose=verbose, solver=cp.SCS)
                except Exception as ee:
                    PyLog.warning(f"SCS failure third level tolerance. td: caught: {ee}")
                    PyLog.warning(traceback.format_exc())

            numIters = problem.solver_stats.num_iters

        return numIters


    @classmethod
    def runOptimal(cls, rebalConfig=RebalConfig_DEFAULT, signalDate=PyDate.asDate(20191115), NAV=100_000_000,
                   solver=cp.SCS, maxIter=200000, cycleIter=5000, verbose=True, checkDCP=True, tradeRestrictions=None):

        return cls.run(rebalConfig=rebalConfig, signalDate=signalDate, preOptWeights=cls.EMPTY_PORTFOLIO,
                       NAV=NAV, solver=solver, maxIter=maxIter, cycleIter=cycleIter,
                       verbose=verbose, checkDCP=checkDCP, optimal=True, tradeRestrictions=tradeRestrictions)

    @classmethod
    def generateRiskReport(cls, dctOptResult, weightCol='postOptWeights', maxReturnDate=PyDate.today()):
        rebalConfig = dctOptResult['rebalConfig']
        portWeights = FrameUtil.toSeries(dctOptResult['dfWeights'], keyCol='assetKey', valCol=weightCol)
        return RiskReport.runPortfolio(port       = portWeights,
                                       signalDate = dctOptResult['signalDate'],
                                       alphaName  = rebalConfig.alphaName,
                                       fcostName  = rebalConfig.fcostName,
                                       bcostName  = rebalConfig.bcostName,
                                       rmodelName = rebalConfig.rmodelName,
                                       envName    = Strategy.getModelName(rebalConfig.stratName),
                                       minWeight  = rebalConfig.holdingThreshold,
                                       maxReturnDate = maxReturnDate)

    @classmethod
    def printRiskReport(cls, dctOptResult, plotChart=False):
        dctReport = cls.generateRiskReport(dctOptResult)
        return RiskReport.print(dctReport, plotChart)

    
    @classmethod
    def compileShortAvailability(cls, rebalConfig, NAV, tradeDate, dframe):
        lookback = rebalConfig.bcostLookback
        endTD = tradeDate
        startTD = PyDate.minusWeekdays(endTD, lookback - 1)
        dfWgts = pd.DataFrame({'tradeDate': PyDate.sequenceWeekday(startTD, endTD, decreasing=True),
                               'timeWgts': Stats.expwts(lookback, lookback/2)})
        dfWgts = dfWgts.assign(timeScale = dfWgts['timeWgts'] / dfWgts['timeWgts'].iloc[0])

        dfRaw = ShortAvailability.getRange(startTD, endTD, pbList=rebalConfig.pbList)
        dfRaw = dfRaw[dfRaw['assetKey'].isin(dframe['assetKey'])]


        if rebalConfig.HAUM:
            AUM = rebalConfig.hypotheticalAUM
        else:
            AUM = NAV

        ########## THIS BLOCK IS NOT NEEDED SINCE WE DON'T HAVE JPM AS BROKER ############
        """
        dfRaw = dfRaw.assign(
            scale=rebalConfig.maxShortUtilization
                  + (1 - rebalConfig.maxShortUtilization)
                  * ((dfRaw['pbCode'] == 'JPM') & (dfRaw['category'] == 'connect')).astype(int))
        """
        ########## THIS BLOCK IS NOT NEEDED SINCE WE DON'T HAVE JPM AS BROKER ############

        # So max shorting depends only only this config variable.
        dfRaw = dfRaw.assign(scale = rebalConfig.maxShortUtilization)

        dfRaw = dfRaw.assign(vsHAUM=dfRaw['scale'] * dfRaw['notionalUSD'] / AUM)
        dfRaw = dfRaw.merge(dfWgts[['tradeDate', 'timeScale']], how='left', on='tradeDate')
        ## availableRate is float and it works poorly with group()
        dfRaw = dfRaw.assign(rateClass=['{:.4f}'.format(x) for x in dfRaw['availableRate']])


        dfMarginal = dfRaw[dfRaw['tradeDate'] == tradeDate].\
            groupby('assetKey').agg({'vsHAUM': sum}).reset_index().\
            rename(columns={'vsHAUM': 'availablePct'})

        ### THIS IS INCORRECTLY SUMMING UP ALL THE EXPONENTIAL DAYS ###
        #dfConsol = dfRaw.groupby(['assetKey', 'rateClass']). \
            #apply(lambda dfm: (dfm['vsHAUM'] * dfm['timeScale']).sum()). \
            #reset_index().rename(columns={0: 'availablePct'})
        
        dfConsol = dfRaw.groupby(['assetKey', 'rateClass']) \
                    .apply(lambda dfm: (dfm['vsHAUM'] * dfm['timeScale']).sum() / dfm['timeScale'].sum()) \
                    .reset_index().rename(columns={0: 'availablePct'})

        dfConsol = dfConsol.assign(availableRate=dfConsol['rateClass'].astype(float))
        dfConsol = dfConsol.sort_values(by=['assetKey', 'availableRate'], ascending=True). \
            groupby('assetKey').apply(lambda dfm: dfm.assign(cumHi=dfm['availablePct'].cumsum())).reset_index(drop=True)
        dfConsol = dfConsol.groupby('assetKey'). \
            apply(lambda dfm: dfm.assign(cumLo=[0.0] + dfm['cumHi'][:-1].tolist())).reset_index(drop=True)
        dfConsol = dfConsol.merge(dframe[['assetKey', 'preOptWeights']], how='left', on='assetKey')

        ## discard irrelevant availability
        softShortBoundBuffer = 0.01
        dfConsol = dfConsol.merge(dframe[['assetKey', 'shortBoundSoft', 'shortBoundHard']], how='left', on='assetKey')
        dfConsol = dfConsol.assign(bufferedShortBoundSoft=dfConsol['shortBoundSoft'] + softShortBoundBuffer)
        dfConsol = dfConsol[dfConsol['cumLo'] <= dfConsol['shortBoundHard']]
        dfConsol = dfConsol[dfConsol['cumLo'] <= dfConsol['bufferedShortBoundSoft']]

        ## technically, this is not strict but we are going to simplify the logic and keep track of
        ## all availability and marginal availability
        dfConsol = dfConsol.sort_values(by=['assetKey', 'availableRate'], ascending=True). \
            groupby('assetKey').apply(lambda dfm: dfm.assign(tier=range(len(dfm)))).reset_index(drop=True)

        columns = ['assetKey', 'tier', 'availableRate', 'availablePct']
        dfBaseTier = dfConsol[dfConsol['tier'] == 0][columns].merge(dframe, how='inner', on='assetKey')

        #dfBaseTier = dfBaseTier.assign(bcost=dfBaseTier['availableRate'] / 100)
        dfBaseTier = dfBaseTier.assign(bcost=dfBaseTier['availableRate'] )

        # dfBaseTier = dfBaseTier.assign(shortBoundHard = dfBaseTier[['shortBoundHard', 'availablePct']].min(axis=1))
        dfBaseTier = dfBaseTier.assign(shortBoundHard=dfBaseTier['availablePct'])
        dfBaseTier = dfBaseTier.drop(columns=['availableRate', 'availablePct'])

        dfHighTier = dfConsol[dfConsol['tier'] != 0][['assetKey', 'tier', 'availableRate', 'availablePct']]. \
            merge(dframe, how='inner', on='assetKey')
        dfHighTier = dfHighTier.assign(longBoundHard=0.0)
        dfHighTier = dfHighTier.assign(shortBoundHard=dfHighTier['availablePct'])
        
        #dfHighTier = dfHighTier.assign(bcost=dfHighTier['availableRate'] / 100)
        dfHighTier = dfHighTier.assign(bcost=dfHighTier['availableRate'] )

        dfHighTier = dfHighTier.assign(fcost=1.00)
        dfHighTier = dfHighTier.assign(preOptWeights=np.NaN)
        dfHighTier = dfHighTier.drop(columns=['availableRate', 'availablePct'])

        ## no short availability
        dfNoAvail = dframe[~dframe['assetKey'].isin(dfConsol['assetKey'])]
        dfNoAvail = dfNoAvail.assign(shortBoundHard=0.0)
        dfNoAvail = dfNoAvail.assign(bcost=1.0)
        dfNoAvail = dfNoAvail.assign(tier=-1)

        dfAvail = pd.concat([dfBaseTier, dfHighTier, dfNoAvail], ignore_index=True)
        dfAvail = dfAvail.assign(key=dfAvail['assetKey'] + ':' + dfAvail['tier'].astype(str))
        dfAvail = dfAvail.sort_values(by=['assetKey', 'tier'])


        ## bcostMultiplier
        if rebalConfig.bcostMultiplierName is not None:
            signalDate = PyDate.prevWeekday(tradeDate)
            signalObj = Signal.registryLookup(rebalConfig.bcostMultiplierName)
            dfm = SignalMgr.getFrame(signalObj.signalName, signalDate).\
                rename(columns={signalObj.signalName: 'multiplier'})
            dfAvail = dfAvail.merge(dfm[['assetKey', 'multiplier']], how='left', on='assetKey')
            dfAvail = dfAvail.assign(multiplier = dfAvail['multiplier'].fillna(signalObj.maxMultiplier))
            dfAvail = dfAvail.assign(bcost = dfAvail['bcost'] * dfAvail['multiplier'])
            dfAvail = dfAvail.drop(columns='multiplier')


        return dfAvail, dfMarginal


def load_china_connect_inventory_scores(input_date, max_lookback_days):
    """
    Try to read China Connect Inventory Score file for the given signal date.
    From what I have noticed, for tradeDate say 11/19, we get files for  11/18 and that 
    too at 9pm NY time but our optimizer runs at 3pm NY time so these 11/18 files come late.
    So we process only two days earlier files for 11/17. Hence I have put in logic for max_lookback_days
    as 3 so we will try to get trade_date(say 11/19) files, if not (11/18), if not (11/17)
    Returns dataframe ['assetKey','stabilityScore'] or None.
    """
    import PyUtil.PyRicX as RicX

    try:
        pbdata_dir = os.environ.get('PBDIR')
        if not pbdata_dir:
            PyLog.info("ChinaConnect: PBDIR env var not set; skipping China Connect short penalty.")
            return None

        base_dir = os.path.join(pbdata_dir, "GS_Stability_Files")
        if not os.path.isdir(base_dir):
            PyLog.info(f"ChinaConnect: directory {base_dir} does not exist; skipping China Connect short penalty.")
            return None

        # ----------------------------------------------------------
        # NEW: Loop over working days (signalDate, -1 WD, -2 WD, etc.)
        # ----------------------------------------------------------
        dates_to_try = []
        d = PyDate.asDate(input_date)

        for _ in range(max_lookback_days):
            dates_to_try.append(d)
            d = PyDate.prevWeekday(d)

        filename = None

        for d in dates_to_try:
            date_str = d.strftime("%Y%m%d")
            pattern_xls  = os.path.join(base_dir, f"*China_Connect*In*{date_str}.xls")
            pattern_xlsx = os.path.join(base_dir, f"*China_Connect*In*{date_str}.xlsx")

            files = glob.glob(pattern_xls) + glob.glob(pattern_xlsx)

            if files:
                files.sort(key=os.path.getmtime)
                filename = files[-1]  # newest file for that date
                PyLog.info(f"ChinaConnect: using inventory score file {filename} (date {date_str})")
                break
            else:
                PyLog.info(f"ChinaConnect: no file for date {date_str}, trying previous working day...")

        if not filename:
            PyLog.info(f"ChinaConnect: no China_Connect file found in last {max_lookback_days} working days; skipping penalty.")
            return None
        # ----------------------------------------------------------

        # Read without header; find the row where the table starts
        raw = pd.read_excel(filename, header=None)
        col0 = raw.iloc[:, 0].astype(str).str.strip()
        header_rows = raw.index[col0 == "RIC"].tolist()
        if not header_rows:
            PyLog.info(f"ChinaConnect: could not find header row with 'RIC' in {filename}; skipping penalty.")
            return None

        h = header_rows[0]
        df = raw.iloc[h:].copy()
        df.columns = df.iloc[0]
        df = df.iloc[1:]  # drop header row

        df = df.rename(columns={c: str(c).strip() for c in df.columns})
        expected_cols = {"RIC", "SCORE", "SEDOL", "ISIN"}
        if not expected_cols.issubset(df.columns):
            PyLog.info(f"ChinaConnect: expected columns {expected_cols} not found; skipping penalty.")
            return None

        df = df[["RIC", "SCORE", "SEDOL", "ISIN"]].copy()
        df["RIC"] = df["RIC"].astype(str).str.strip()
        df["SCORE"] = pd.to_numeric(df["SCORE"], errors="coerce")

        # Keep only ZK and SH
        mask = df["RIC"].str.endswith(".ZK") | df["RIC"].str.endswith(".SH")
        df = df[mask].copy()
        if df.empty:
            PyLog.info("ChinaConnect: no .ZK/.SH rows in file; nothing to do.")
            return None

        # Convert to internal_ric: ZK->SZ, SH->SS
        df["internal_ric"] = df["RIC"]
        df.loc[df["RIC"].str.endswith(".ZK"), "internal_ric"] = df["RIC"].str.replace(".ZK", ".SZ", regex=False)
        df.loc[df["RIC"].str.endswith(".SH"), "internal_ric"] = df["RIC"].str.replace(".SH", ".SS", regex=False)

        # Map to assetKey
        df = RicX.add_asset_code(df, "internal_ric")
        if "assetKey" not in df.columns:
            PyLog.info("ChinaConnect: add_asset_code failed; skipping penalty.")
            return None

        df = df[["assetKey", "SCORE"]].dropna()
        if df.empty:
            PyLog.info("ChinaConnect: no usable rows remain; skipping penalty.")
            return None

        # Aggregate duplicates by max score
        df = df.groupby("assetKey", as_index=False)["SCORE"].max()
        df = df.rename(columns={"SCORE": "stabilityScore"})

        PyLog.info(f"ChinaConnect: loaded {len(df)} inventory score rows with assetKey.")
        return df

    except Exception as e:
        PyLog.error(f"ChinaConnect: exception while loading inventory scores: {e}")
        PyLog.error(traceback.format_exc())
        return None

def build_china_connect_short_penalty(dfAvail, wshort, input_date):

    # For now we will hardcode max_lookback_days here, ideally it should be in rebalance.config
    max_lookback_days = 3

    # Load scores from file

    df_scores = load_china_connect_inventory_scores(input_date, max_lookback_days)

    if df_scores is None or df_scores.empty:
        return None

    # Map assetKey -> stabilityScore
    df_scores = df_scores.set_index("assetKey")["stabilityScore"]

    if "assetKey" not in dfAvail.columns:
        PyLog.info("ChinaConnect: dfAvail has no 'assetKey' column; cannot apply stability penalty.")
        return None

    n = len(dfAvail)
    penalties = np.zeros(n)
    penalized_assets = []  # (idx, assetKey, score, penaltyWeight)

    # Build penalty weights per dfAvail row
    for idx, a in enumerate(dfAvail["assetKey"]):
        if a in df_scores.index:
            s = df_scores.loc[a]
            if pd.notna(s) and s > 4:
                # 5→1, 6→2, 7→3, 8→4 (can be tuned)
                pw = max(0.0, float(s) - 4.0)
                penalties[idx] = pw
                penalized_assets.append((idx, a, float(s), pw))

    if not penalized_assets:
        PyLog.info("ChinaConnect: no dfAvail assets with stabilityScore > 4; no penalty applied.")
        return None

    # Log penalized assets BEFORE solve (no short weights yet)
    PyLog.info(f"ChinaConnect: {len(penalized_assets)} dfAvail assets will have short-side stability penalty.")
    PyLog.info("ChinaConnect: penalized assets (before solve, top 50):")
    for idx, a, s, pw in penalized_assets[:50]:
        PyLog.info(f"    idx={idx}, assetKey={a}, score={s}, penaltyWeight={pw}")
    if len(penalized_assets) > 50:
        PyLog.info(f"    ...and {len(penalized_assets) - 50} more penalized assets")

    # Optional tuning knob (strength of this penalty)
    china_scale = 0.1  # adjust as needed or make configurable

    # penalties is constant data; cvxpy treats it as coefficients
    penalties_vec = penalties.reshape(-1, 1)
    penalty_expr = china_scale * cp.sum(cp.multiply(penalties_vec, wshort))

    # Attach metadata so we can inspect after solve
    penalty_expr._china_penalty_info = penalized_assets
    penalty_expr._china_penalty_weights = penalties_vec
    penalty_expr._china_scale = china_scale

    PyLog.info("ChinaConnect: short-side stability penalty term added to objective.")
    return penalty_expr

def build_trbc_cluster_penalty(
    dframe,
    assets,
    aggrw,
    tradeDate,
    gqr_dir=GQR_SPECRET_DIR,
    corr_threshold=0.5,
    min_cluster_size=2,
    lambda_cluster=0.5,
    penalize_by="PENALIZE_NET_EXPOSURE",
):

    activity_col = "Activity"

    # If we don't have activity information, bail out quietly
    if activity_col not in dframe.columns:
        PyLog.info(f"TRBC cluster penalty: column {activity_col} not found → skipping")
        return 0

    date_str = tradeDate.strftime("%Y%m%d")
    var_path = os.path.join(gqr_dir, f"gqr_specific_var.{date_str}.csv")
    scov_path = os.path.join(gqr_dir, f"gqr_scov_sparse.{date_str}.csv")

    if not os.path.exists(var_path) or not os.path.exists(scov_path):
        PyLog.info(
            f"TRBC cluster penalty: missing var/scov for {date_str} "
            f"(var={os.path.exists(var_path)}, scov={os.path.exists(scov_path)}) → skipping"
        )
        return 0, None

    try:
        var_df = pd.read_csv(var_path)
        var_df = var_df.set_index("assetKey")["spec_var"].to_dict()
        scov_df = pd.read_csv(scov_path)
    except Exception as e:
        PyLog.info(f"TRBC cluster penalty: failed to read var/scov for {date_str}: {e} → skipping")
        return 0, None

    # Map assetKey -> activity for current universe
    df_act = (
        dframe[["assetKey", activity_col]]
        .dropna()
        .drop_duplicates(subset=["assetKey"])
        .set_index("assetKey")
    )
    act_map = df_act[activity_col].to_dict()

    # Map assetKey -> index in aggrw
    key_to_idx = {}
    for i, k in enumerate(assets):
        key_to_idx[k] = i

    # Build adjacency by activity using high-|corr| pairs
    adjacency = {}
    edges_used = 0

    for _, row in scov_df.iterrows():
        x = row["xKey"]
        y = row["yKey"]

        ax = act_map.get(x, None)
        ay = act_map.get(y, None)
        if ax is None or ay is None:
            continue

        # Only within same TRBC activity
        if ax != ay:
            continue

        vx = var_df.get(x, None)
        vy = var_df.get(y, None)
        if vx is None or vy is None or vx <= 0 or vy <= 0:
            continue

        cov_xy = row["cov"]
        # Convert sparse specific covariance to correlation
        corr_xy = cov_xy / math.sqrt(vx * vy)

        if abs(corr_xy) < corr_threshold:
            continue

        # Only keep edges where both sides are in the optimization universe
        if x not in key_to_idx or y not in key_to_idx:
            continue

        i = key_to_idx[x]
        j = key_to_idx[y]

        if ax not in adjacency:
            adjacency[ax] = {}
        if i not in adjacency[ax]:
            adjacency[ax][i] = set()
        if j not in adjacency[ax]:
            adjacency[ax][j] = set()

        adjacency[ax][i].add(j)
        adjacency[ax][j].add(i)
        edges_used += 1

    if edges_used == 0:
        PyLog.info(f"TRBC cluster penalty: no within-activity high-corr pairs for {date_str} → skipping")
        return 0, None

    # Find connected components (clusters) within each activity
    clusters = []
    for act, adj in adjacency.items():
        visited = set()
        for node in list(adj.keys()):
            if node in visited:
                continue
            # BFS / DFS to collect a cluster
            stack = [node]
            comp = []
            while stack:
                u = stack.pop()
                if u in visited:
                    continue
                visited.add(u)
                comp.append(u)
                for v in adj.get(u, []):
                    if v not in visited:
                        stack.append(v)

            if len(comp) >= min_cluster_size:
                clusters.append((act, sorted(comp)))

    if not clusters:
        PyLog.info(
            f"TRBC cluster penalty: no clusters of size >= {min_cluster_size} for {date_str} → skipping"
        )
        return 0, None

    # Write clusters out so we can log exposures later
    try:
        rows = []
        for cluster_id, (act, idx_list) in enumerate(clusters):
            for idx in idx_list:
                rows.append(
                    {
                        "date": date_str,
                        "activity": act,
                        "cluster_id": act,
                        "idx": idx,
                        "assetKey": assets[idx],
                    }
                )
        cluster_df = pd.DataFrame(rows)
    except Exception as e:
        PyLog.info(f"TRBC cluster penalty: failed to write clusters CSV for {date_str}: {e}")

    # --- NEW: build penalty expression depending on penalize_by mode ---

    penalty_terms = []

    # Normalise flag and accept both the "correct" and typo'd third-mode name
    flag = (penalize_by or "PENALIZE_NET_EXPOSURE").upper()
    high_pe_high_vol_flags = {
        "PENALIZE_NET_EXPOSURE_HIGH_PE_AND_HIGH_VOLATILE_SECTORS",
    }
    is_high_pe_high_vol_mode = flag in high_pe_high_vol_flags

    # In the special third mode, pre-compute which Activities are both high-vol and high-P/E
    penalized_activities = None
    if is_high_pe_high_vol_mode:
        # Activity-level volatility from per-stock 'annualized'
        act_vol = compute_activity_volatility(
            dframe,
            activity_col=activity_col,
            vol_col="annualized",
            agg_method="median",
        )
        # Activity-level P/E (currently returns {} until I  wire pe_col)
        act_pe = compute_activity_pe(
            dframe,
            activity_col=activity_col,
            pe_col=None,        
            agg_method="median",
        )

        if not act_vol or not act_pe:
            PyLog.info(
                "TRBC cluster penalty: high-PE/high-vol mode selected but missing "
                "volatility or P/E data → no Activities will be penalized in this mode."
            )
            penalized_activities = set()  # effectively disables this mode
        else:
            # thresholds
            USE_RELATIVE_THRESHOLDS = False   # set True to switch to relative mode
            VOL_ABS_THRESHOLD = 0.40          # 40% annualised vol
            PE_ABS_THRESHOLD = 20.0           # P/E > 20
            RELATIVE_TOP_FRACTION = 0.30      # top 30% by vol and P/E

            vol_values = np.array(list(act_vol.values()), dtype=float)
            pe_values = np.array(list(act_pe.values()), dtype=float)

            if USE_RELATIVE_THRESHOLDS:
                # Top 30% = 70th percentile cutoff
                vol_cutoff = float(np.quantile(vol_values, 1.0 - RELATIVE_TOP_FRACTION))
                pe_cutoff = float(np.quantile(pe_values, 1.0 - RELATIVE_TOP_FRACTION))
            else:
                vol_cutoff = VOL_ABS_THRESHOLD
                pe_cutoff = PE_ABS_THRESHOLD

            high_vol_acts = {
                a for a, v in act_vol.items() if v is not None and v >= vol_cutoff
            }
            high_pe_acts = {
                a for a, v in act_pe.items() if v is not None and v >= pe_cutoff
            }
            penalized_activities = high_vol_acts & high_pe_acts

            PyLog.info(
                f"TRBC cluster penalty: high-PE/high-vol mode → "
                f"{len(penalized_activities)} activities meet both criteria "
                f"(vol_cutoff={vol_cutoff:.3f}, pe_cutoff={pe_cutoff:.3f})"
            )

    # Build penalty per cluster
    for act, idx_list in clusters:
        if flag == "PENALIZE_ALL_STOCKS":
            # Penalise each stock in the cluster: sum_i w_i^2
            cluster_pen = cp.sum_squares(aggrw[idx_list])
            penalty_terms.append(cluster_pen)

        elif flag == "PENALIZE_NET_EXPOSURE":
            # Penalise only net exposure: (sum_i w_i)^2
            exposure_C = cp.sum(aggrw[idx_list])
            penalty_terms.append(cp.square(exposure_C))

        elif is_high_pe_high_vol_mode:
            # Only penalize clusters whose Activity is both high-vol and high-P/E
            if penalized_activities and act not in penalized_activities:
                continue
            exposure_C = cp.sum(aggrw[idx_list])
            penalty_terms.append(cp.square(exposure_C))

        else:
            # Fallback: default to net-exposure penalty if unknown flag
            exposure_C = cp.sum(aggrw[idx_list])
            penalty_terms.append(cp.square(exposure_C))

    if not penalty_terms:
        PyLog.info(
            "TRBC cluster penalty: no clusters selected under penalize_by="
            f"{penalize_by} → returning 0"
        )
        return 0

    pen_expr = lambda_cluster * cp.sum(penalty_terms)

    PyLog.info(
        f"TRBC cluster penalty: built {len(clusters)} clusters from {edges_used} edges for {date_str}, "
        f"lambda={lambda_cluster}, penalize_by={penalize_by}"
    )
    return pen_expr, cluster_df


def apply_high_vol_activity_hard_bounds(
    dframe,
    dfAvail=None,
    activity_col="Activity",
    vol_col="annualized",
    vol_abs_threshold=0.40,
    tol=1e-8,
    tradeDate=None,
):
    # 0) Ensure we have volatility in dframe; if not, pull Parkinson vol here
    if vol_col not in dframe.columns:
        try:
            df_parkinson = SignalMgr.get("parkinson_volatility_frame", tradeDate)
        except Exception as e:
            PyLog.info(f"apply_high_vol_activity_hard_bounds: failed SignalMgr.get parkinson_volatility_frame: {e}")
            return dframe, dfAvail, False
        
        if df_parkinson is None or len(df_parkinson) == 0:
            PyLog.info("apply_high_vol_activity_hard_bounds: parkinson_volatility_frame empty → skipping")
            return dframe, dfAvail, False

        needed = [c for c in ["assetKey", "annualized"] if c in df_parkinson.columns]
        if "assetKey" not in needed or "annualized" not in needed:
            PyLog.info("apply_high_vol_activity_hard_bounds: parkinson frame missing assetKey/annualized → skipping")
            return dframe, dfAvail, False

        df_parkinson = df_parkinson[needed].drop_duplicates(subset=["assetKey"])
        dframe = dframe.merge(df_parkinson, how="left", on="assetKey")
        PyLog.info(
            f"apply_high_vol_activity_hard_bounds: merged annualized vol for "
            f"{dframe['annualized'].notna().sum()} assets"
        )

    # Activity-level volatility
    act_vol = compute_activity_volatility(
        dframe,
        activity_col=activity_col,
        vol_col=vol_col,
        agg_method="median",
    )

    if not act_vol:
        PyLog.info("apply_high_vol_activity_hard_bounds: no activity vol → skipping")
        return dframe, dfAvail, False

    # Pick Activities that are "high-vol"
    bad_activities = {
        act for act, v in act_vol.items()
        if v is not None and v >= vol_abs_threshold
    }

    if not bad_activities:
        PyLog.info(f"apply_high_vol_activity_hard_bounds: no activities above {vol_abs_threshold:.3f}")

        # Construct EMPTY DataFrame with correct columns
        out_df = pd.DataFrame(columns=[
            "assetKey", activity_col, "preOptWeights", "longBoundHard", "shortBoundHard", "tradeDate"
        ])

        # Fill tradeDate so filename is correct
        date_str = tradeDate.strftime("%Y%m%d") if tradeDate is not None else "UNKNOWN"

        logdir = os.environ.get("LOGDIR", "/data/log")
        os.makedirs(os.path.join(logdir, "trbc_cluster_penalty"), exist_ok=True)

        excel_path = os.path.join(logdir, "trbc_cluster_penalty", f"trbc_cluster_report.{date_str}.xlsx")

        # Write EMPTY sheet with headers only
        with pd.ExcelWriter(excel_path, engine="openpyxl") as writer:
            out_df.to_excel(writer, sheet_name="HighVol_Activity_Bounds", index=False)

        PyLog.info(f"apply_high_vol_activity_hard_bounds: created EMPTY Excel report {excel_path}")

        return dframe, dfAvail, False

    # Sanity checks for required columns
    if activity_col not in dframe.columns:
        PyLog.info("apply_high_vol_activity_hard_bounds: activity column missing → skipping")
        return dframe, dfAvail, False

    if "preOptWeights" not in dframe.columns:
        PyLog.info("apply_high_vol_activity_hard_bounds: preOptWeights column missing → skipping")
        return dframe, dfAvail, False

    if "longBoundHard" not in dframe.columns or "shortBoundHard" not in dframe.columns:
        PyLog.info("apply_high_vol_activity_hard_bounds: hard bound columns missing → skipping")
        return dframe, dfAvail, False

    # Identify rows in high-vol Activities
    mask_bad = dframe[activity_col].isin(bad_activities).to_numpy()
    if not mask_bad.any():
        PyLog.info("apply_high_vol_activity_hard_bounds: no rows in bad activities → skipping")
        return dframe, dfAvail, False

    # Get current weights and bounds as numpy arrays
    cur_w = dframe["preOptWeights"].astype(float).fillna(0.0).to_numpy()
    lbh   = dframe["longBoundHard"].astype(float).fillna(0.0).to_numpy()
    sbh   = dframe["shortBoundHard"].astype(float).fillna(0.0).to_numpy()

    # For each name in a high-vol Activity:
    # If currently long:  lbh = min(lbh, current_long), sbh = 0
    # If currently short: sbh = min(sbh, |current_short|), lbh = 0
    # If flat:            lbh = 0, sbh = 0
    for i in range(len(dframe)):
        if not mask_bad[i]:
            continue

        w0 = cur_w[i]
        if w0 > tol:
            lbh[i] = min(lbh[i], w0)
            sbh[i] = 0.0
        elif w0 < -tol:
            sbh[i] = min(sbh[i], -w0)
            lbh[i] = 0.0
        else:
            lbh[i] = 0.0
            sbh[i] = 0.0

    dframe = dframe.copy()
    dframe["longBoundHard"]  = lbh
    dframe["shortBoundHard"] = sbh

    n_rows = int(mask_bad.sum())

    PyLog.info(
        f"apply_high_vol_activity_hard_bounds: {len(bad_activities)} high-vol activities, "
        f"{n_rows} rows with tightened bounds"
    )

    # Propagate matching bounds into dfAvail (splitLongShort case)
    if dfAvail is not None and isinstance(dfAvail, pd.DataFrame) and "assetKey" in dfAvail.columns:
        try:
            key_to_lbh = dict(zip(dframe["assetKey"], dframe["longBoundHard"]))
            key_to_sbh = dict(zip(dframe["assetKey"], dframe["shortBoundHard"]))

            bad_keys = set(dframe.loc[mask_bad, "assetKey"])
            mask_av  = dfAvail["assetKey"].isin(bad_keys)

            if "longBoundHard" in dfAvail.columns:
                dfAvail.loc[mask_av, "longBoundHard"] = dfAvail.loc[mask_av, "assetKey"].map(key_to_lbh)

            if "shortBoundHard" in dfAvail.columns:
                dfAvail.loc[mask_av, "shortBoundHard"] = dfAvail.loc[mask_av, "assetKey"].map(key_to_sbh)

        except Exception as e:
            PyLog.info(f"apply_high_vol_activity_hard_bounds: failed to update dfAvail: {e}")


    ### Create high-vol activity bounds CSV + Excel Tab 1
    try:
        out_df = dframe.loc[
            mask_bad,
            ["assetKey", activity_col, "preOptWeights", "longBoundHard", "shortBoundHard"]
        ].copy()

        out_df["tradeDate"] = tradeDate.strftime("%Y%m%d") if tradeDate is not None else None


        logdir = os.environ.get("LOGDIR", "/data/log")
        os.makedirs(os.path.join(logdir, "trbc_cluster_penalty"), exist_ok=True)
        date_str = out_df["tradeDate"].iloc[0]

        # Now create the Excel report (Tab 1 only)
        excel_path = os.path.join(logdir, "trbc_cluster_penalty", f"trbc_cluster_report.{date_str}.xlsx")

        with pd.ExcelWriter(excel_path, engine="openpyxl") as writer:
            out_df.to_excel(writer, sheet_name="HighVol_Activity_Bounds", index=False)

        PyLog.info(f"apply_high_vol_activity_hard_bounds: created Excel report {excel_path}")

    except Exception as e:
        PyLog.info(f"apply_high_vol_activity_hard_bounds: failed to write Excel/logs: {e}")


    return dframe, dfAvail, True


def compute_activity_volatility(
    dframe,
    activity_col="Activity",
    vol_col="annualized",
    agg_method="median",
):
    if activity_col not in dframe.columns or vol_col not in dframe.columns:
        PyLog.info(
            f"compute_activity_volatility: missing columns "
            f"{activity_col} or {vol_col} → returning empty dict"
        )
        return {}

    df = dframe[[activity_col, vol_col]].dropna()

    if df.empty:
        PyLog.info("compute_activity_volatility: no data after dropna → returning empty dict")
        return {}

    if agg_method == "median":
        series = df.groupby(activity_col)[vol_col].median()
    elif agg_method == "mean":
        series = df.groupby(activity_col)[vol_col].mean()
    else:
        PyLog.info(f"compute_activity_volatility: unknown agg_method={agg_method} → using median")
        series = df.groupby(activity_col)[vol_col].median()

    return series.to_dict()

def log_trbc_cluster_exposures(
    tradeDate,
    assets,
    aggrw,
    cluster_df,
    gqr_dir=GQR_SPECRET_DIR,
    lambda_cluster=None,
):

    if cluster_df is None or cluster_df.empty:
        PyLog.info("TRBC cluster exposure: no cluster_df → skipping")
        return

    df_cl = cluster_df.copy()

    try:
        date_str = tradeDate.strftime("%Y%m%d")

        logdir = os.environ.get("LOGDIR", "/data/log")
        os.makedirs(os.path.join(logdir, "trbc_cluster_penalty"), exist_ok=True)
        excel_path = os.path.join(logdir, "trbc_cluster_penalty", f"trbc_cluster_report.{date_str}.xlsx")

        # === Map weights ===
        wvec = np.asarray(aggrw.value).reshape(-1)
        key_to_idx = {k: i for i, k in enumerate(assets)}

        df_cl["weight"] = df_cl["assetKey"].map(lambda ak: wvec[key_to_idx[ak]] if ak in key_to_idx else np.nan)
        df_cl["abs_weight"] = df_cl["weight"].abs()

        # === Aggregate exposures at cluster level ===
        grp = df_cl.groupby(["date", "activity", "cluster_id"], as_index=False).agg(
            n_assets=("assetKey", "nunique"),
            cluster_net_w=("weight", "sum"),
            cluster_gross_w=("abs_weight", "sum"),
            max_gross_w=("abs_weight", "max"),
        )

        # === penalty column ===
        if lambda_cluster is not None:
            grp["cluster_penalty"] = lambda_cluster * grp["cluster_net_w"] ** 2
        else:
            grp["cluster_penalty"] = 0.0

        # === Identify max clusters ===
        max_net_row = grp.iloc[grp["cluster_net_w"].abs().idxmax()].to_frame().T
        max_gross_row = grp.iloc[grp["cluster_gross_w"].idxmax()].to_frame().T
        max_pen_row = grp.iloc[grp["cluster_penalty"].idxmax()].to_frame().T

        # === Write Tabs 2–6 to Excel (append mode) ===
        with pd.ExcelWriter(excel_path, mode="a", engine="openpyxl", if_sheet_exists="replace") as writer:
            df_cl.to_excel(writer, sheet_name="TRBCclusters", index=False)
            grp.to_excel(writer, sheet_name="TRBCclusterExposure", index=False)
            max_net_row.to_excel(writer, sheet_name="MaxNetCluster", index=False)
            max_gross_row.to_excel(writer, sheet_name="MaxGrossCluster", index=False)
            max_pen_row.to_excel(writer, sheet_name="MaxPenaltyCluster", index=False)

        PyLog.info(f"TRBC cluster exposure Excel updated → {excel_path}")

    except Exception as e:
        PyLog.info(f"TRBC exposure: failed for {tradeDate}: {e}")

########  End of TRBC Activity cluster enhancement ####################

# === Conditioning Functions (updated with ModelCntry merge) ===

def get_returns_history(signalDate, lookback):
    import datetime as _dt

    # Handle signalDate as int/str YYYYMMDD or date/datetime
    try:
        # if already like 20251128 or "20251128"
        end_date = _dt.datetime.strptime(str(signalDate), "%Y%m%d")
    except Exception:
        # assume it's a date/datetime
        if isinstance(signalDate, _dt.datetime):
            end_date = signalDate
        else:
            end_date = _dt.datetime.combine(signalDate, _dt.time.min)

    start_date = end_date - _dt.timedelta(days=lookback)

    frames = []

    print(f"ALPHA CONIDTIONING START DATE {start_date} END DATE{end_date}")
    for cur in pd.date_range(start_date, end_date):
        # skip weekends
        if cur.weekday() in (5, 6):
            continue

        cur_str = cur.strftime("%Y%m%d")

        rf = SignalMgr.get("returns_frame", cur_str)
        if rf is not None and len(rf) > 0:
            frames.append(rf)

    print(f"LENGTH FRAMES = {len(frames)}")
    return frames


def compute_trend_scores(signalDate, rebalConfig, dframe=None):
    """
    Returns per-asset momentum z-score (NOT the final multiplier).
    We'll turn it into a multiplier inside apply_alpha_conditioning
    so we can use the alpha sign to decide confirm vs conflict.
    """
    if dframe is None or dframe.empty:
        PyLog.info("compute_trend_scores: dframe empty; skipping trend conditioning")
        return {}

    lookback = getattr(rebalConfig, 'trendLookback', 40)
    frames   = get_returns_history(signalDate, lookback)
    if not frames:
        PyLog.info("compute_trend_scores: no returns history; skipping")
        return {}

    universe_assets = set(dframe['assetKey'].unique())

    mfm = SignalMgr.getStatic('model_universe_frame')
    if mfm is None or mfm.empty:
        PyLog.info("model_universe_frame empty; skipping trend conditioning")
        return {}

    asia_assets = universe_assets.intersection(mfm['assetKey'].unique())
    if not asia_assets:
        PyLog.info("compute_trend_scores: no Asia assets in trading universe")
        return {}

    # Daily matrix: rows=assets, cols=days
    hist = pd.concat(
        [f.set_index('assetKey')['logretd'] for f in frames],
        axis=1
    ).fillna(0.0)

    hist = hist.loc[hist.index.intersection(asia_assets)]
    if hist.empty:
        PyLog.info("compute_trend_scores: hist empty after filtering")
        return {}

    mom = hist.sum(axis=1)
    z = (mom - mom.mean()) / (mom.std() + 1e-9)

    PyLog.info(f"compute_trend_scores: computed z-scores for {len(z)} assets")
    return z.to_dict()

def compute_trend_scores_v1(signalDate, rebalConfig, dframe=None):

    if dframe is None or dframe.empty:
        PyLog.info("compute_trend_scores: dframe empty; skipping trend conditioning")
        return {}

    lookback = getattr(rebalConfig, 'trendLookback', 40)
    frames   = get_returns_history(signalDate, lookback)
    if not frames:
        PyLog.info("compute_trend_scores: no returns history; skipping")
        return {}

    # --- True universe is today's dframe ---
    universe_assets = set(dframe['assetKey'].unique())

    # --- Allowed countries from config ---
    allowed_countries = set(Universe.getModelCountries())

    # Load ModelCntry mapping
    mfm = SignalMgr.getStatic('model_universe_frame')
    if mfm is None or mfm.empty:
        PyLog.info("model_universe_frame empty; skipping trend conditioning")
        return {}

    mfm = mfm.dropna(subset=['assetKey', 'ModelCntry'])[['assetKey', 'ModelCntry']]
    mfm = mfm[mfm['ModelCntry'].isin(allowed_countries)]

    # Intersection of trade universe and Asia universe
    asia_assets = universe_assets.intersection(mfm['assetKey'].unique())
    if not asia_assets:
        PyLog.info("compute_trend_scores: no Asia assets in trading universe")
        return {}

    # Build daily matrix for only those assets
    hist = pd.concat(
        [f.set_index('assetKey')['logretd'] for f in frames],
        axis=1
    ).fillna(0.0)

    hist = hist.loc[hist.index.intersection(asia_assets)]
    if hist.empty:
        PyLog.info("compute_trend_scores: hist empty after filtering")
        return {}

    # Trend signal
    mom = hist.sum(axis=1)
    mom_norm = (mom - mom.mean()) / (mom.std() + 1e-9)

    mult = (1.0 + 0.2 * mom_norm).clip(0.5, 1.5)

    PyLog.info(f"compute_trend_scores: computed for {len(mult)} assets")
    return mult.to_dict()

def compute_country_vol_scalars(signalDate, rebalConfig, dframe=None):

    if dframe is None or dframe.empty:
        PyLog.info("compute_country_vol_scalars: dframe empty; skipping")
        return {}

    lookback = getattr(rebalConfig, 'volLookback', 40)
    frames   = get_returns_history(signalDate, lookback)
    if not frames:
        PyLog.info("compute_country_vol_scalars: no returns history; skipping")
        return {}

    hist = pd.concat(frames)

    # Today's universe assets
    universe_assets = set(dframe['assetKey'].unique())

    # Allowed Asia countries
    allowed_countries = set(Universe.getModelCountries())

    # Load mapping
    mfm = SignalMgr.getStatic('model_universe_frame')
    if mfm is None or mfm.empty:
        PyLog.info("model_universe_frame empty; skipping vol conditioning")
        return {}

    mfm = mfm.dropna(subset=['assetKey','ModelCntry'])[['assetKey','ModelCntry']]
    mfm = mfm[mfm['ModelCntry'].isin(allowed_countries)]

    # Merge country info into returns
    hist = hist.merge(mfm, on='assetKey', how='left')
    hist['ModelCntry'] = hist['ModelCntry'].fillna("UNKNOWN")

    # Reduce to trade universe assets
    hist = hist[hist['assetKey'].isin(universe_assets)]

    # Compute vol per country
    country_vols = {}
    for c in allowed_countries:
        grp = hist[hist['ModelCntry'] == c]
        if grp.empty:
            continue

        vol = grp['logretd'].std()
        if pd.isna(vol):
            continue

        scalar = 1.0 / (1.0 + 5.0 * vol)
        scalar = max(0.4, min(1.0, scalar))

        PyLog.info(f"compute_country_vol_scalars: {c} → {scalar}")
        country_vols[c] = scalar

    # fallback for unknown
    country_vols["UNKNOWN"] = 1.0

    return country_vols


def trend_multiplier(alpha: float,
                           z: float,
                           k: float = 0.20,
                           min_mult: float = 0.40,
                           max_mult: float = 1.50,
                           z_cap: float = 6.0,
                           short_tail_z: float = 2.5,
                           short_tail_cap: float = 1.00) -> float:
    """
    Base behavior (unchanged from original):
      - multiplier = clip(1 + k*z, min_mult, max_mult)

    Tail safety brake:
      - ONLY if alpha < 0 AND z >= short_tail_z
      - cap multiplier at short_tail_cap (default = 1.0)

    This preserves:
      - mean-reversion shorts
      - policy-regime profitability
      - long-side behavior

    And only protects against:
      - extreme short squeeze tails
    """

    # Safety
    if alpha == 0 or not np.isfinite(alpha) or not np.isfinite(z):
        return 1.0

    # Cap extreme z
    z = float(max(-z_cap, min(z_cap, z)))

    # Original logic
    m = 1.0 + k * z
    m = max(min_mult, min(max_mult, m))

    # Extreme short squeeze brake
    if alpha < 0 and z >= short_tail_z:
        m = min(m, short_tail_cap)

    return float(m)

def apply_alpha_conditioning(dframe, signalDate, rebalConfig):

    trend_z = compute_trend_scores(signalDate, rebalConfig, dframe=dframe)
    vol     = compute_country_vol_scalars(signalDate, rebalConfig, dframe=dframe)

    # knobs (safe defaults)
    k          = getattr(rebalConfig, 'trendK', 0.20)          # sensitivity to |z|
    min_mult   = getattr(rebalConfig, 'trendMinMult', 0.40)    # floor
    max_mult   = getattr(rebalConfig, 'trendMaxMult', 1.50)    # cap
    z_cap      = getattr(rebalConfig, 'trendZCap', 3.0)        # avoid extreme z blowing things up

    count = 0
    total = len(dframe)

    def _trend_mult_legacy_unused(alpha, z):
        if alpha == 0 or not np.isfinite(alpha) or not np.isfinite(z):
            return 1.0

        # cap z to avoid crazy multipliers
        z = float(max(-z_cap, min(z_cap, z)))

        strength = 1.0 + k * abs(z)
        strength = max(1.0, min(max_mult, strength))  # strength is at least 1

        # confirmation vs conflict
        if np.sign(alpha) == np.sign(z):
            m = strength              # boost
        else:
            m = 1.0 / strength        # shrink

        # hard clip final multiplier
        return max(min_mult, min(max_mult, m))

    def _adj(row):
        nonlocal count
        count += 1

        asset = row['assetKey']
        initial_alpha = float(row['alpha'])

        z = float(trend_z.get(asset, 0.0))
        #t = _trend_mult(initial_alpha, z)

        t = trend_multiplier(
                    initial_alpha, z,
                    k=k,
                    min_mult=min_mult,
                    max_mult=max_mult,
                    z_cap=z_cap,
                    short_tail_z=getattr(rebalConfig, 'trendShortTailZ', 2.5),
                    short_tail_cap=getattr(rebalConfig, 'trendShortTailCap', 1.0),
                )

        cntry = row.get('modelCountry', row.get('ModelCntry'))
        v = float(vol.get(cntry, 1.0))

        adjusted = initial_alpha * t * v

        PyLog.info(
            f"apply_alpha_conditioning [{count}/{total}]: {asset} "
            f"alpha {initial_alpha:.6f} → {adjusted:.6f} "
            f"(z={z:.3f}, trendMult={t:.3f}, vol={v:.3f}, country={cntry})"
        )

        return adjusted

    dframe['alpha'] = dframe.apply(_adj, axis=1)
    return dframe

def apply_alpha_conditioning_v1(dframe, signalDate, rebalConfig):
    trend = compute_trend_scores(signalDate, rebalConfig, dframe=dframe)
    vol   = compute_country_vol_scalars(signalDate, rebalConfig, dframe=dframe)

    # Running counter
    count = 0
    total = len(dframe)

    def _adj(row):
        nonlocal count
        count += 1

        asset = row['assetKey']
        initial_alpha = row['alpha']

        t = trend.get(asset, 1.0)

        # prefer 'modelCountry' if exists, else fallback
        cntry = row.get('modelCountry', row.get('ModelCntry'))
        v = vol.get(cntry, 1.0)

        adjusted = initial_alpha * t * v

        PyLog.info(
            f"apply_alpha_conditioning [{count}/{total}]: "
            f"{asset} alpha {initial_alpha:.6f} → {adjusted:.6f} "
            f"(trend={t:.4f}, vol={v:.4f}, country={cntry})"
        )

        return adjusted

    dframe['alpha'] = dframe.apply(_adj, axis=1)
    return dframe

# === End Conditioning Functions ===

