Overall Statistics
from AlgorithmImports import *


class EarningsQualityFactor(QCAlgorithm):

    def initialize(self):
        self.set_start_date(self.end_date - timedelta(5*365))
        self.set_cash(1_000_000)
        self.settings.minimum_order_margin_portfolio_percentage = 0
        self.settings.seed_initial_prices = True 
        self.set_warmup(timedelta(2 * 365 + 45))    
        self._previous_factors = {}
        self._date_rule = self.date_rules.month_end('SPY')
        self._long = []
        self._short = []        
        self.universe_settings.resolution = Resolution.DAILY
        # Add universe of Equities.
        self.universe_settings.schedule.on(self._date_rule)
        self._universe = self.add_universe(self._select)

    def on_warmup_finished(self):
        # Add a Scheduled event to rebalance the portfolio monthly.
        time_rule = self.time_rules.at(8, 0)
        self.schedule.on(self._date_rule, time_rule, self._rebalance)
        # Rebalance the portfolio today too.
        if self.live_mode:
            self._rebalance()
        else:
            self.schedule.on(self.date_rules.today, time_rule, lambda: self._rebalance(True))

    def _select(self, fundamentals):
        if self.time.month != 6:
            return Universe.UNCHANGED
        # Filter non-financial stocks with valid fundamental data and accrual data.
        filtered = [
            f for f in fundamentals
            if (f.has_fundamental_data and 
                f.company_reference.industry_template_code != "B" and 
                f.financial_statements.balance_sheet.current_assets.value and  
                f.financial_statements.balance_sheet.cash_and_cash_equivalents.value and
                f.financial_statements.balance_sheet.current_liabilities.value and
                f.financial_statements.balance_sheet.current_debt.value and
                f.financial_statements.balance_sheet.income_tax_payable.value and
                f.financial_statements.income_statement.depreciation_and_amortization.value)
        ]   
        factors_by_symbol = {f.symbol : AccuralData(f) for f in filtered}
        if not self._previous_factors:
            self._previous_factors = factors_by_symbol
            return Universe.UNCHANGED
        # Calculate accruals for stocks we have previous data for.
        accrual_by_symbol = {
            f.symbol: self._calc_accrual(self._previous_factors[f.symbol], AccuralData(f)) 
            for f in filtered if f.symbol in self._previous_factors
        }
        # Filter stocks with complete factor data.
        filtered = [f for f in filtered if f.symbol in accrual_by_symbol and all(
            not np.isnan(val) and val != 0 for val in [
                f.financial_statements.cash_flow_statement.operating_cash_flow.value,
                f.earning_reports.basic_eps.value,
                f.earning_reports.basic_average_shares.value,
                f.operation_ratios.debt_to_assets.value,
                f.operation_ratios.roe.value
        ])]   
        if not filtered:
            return []
        # Calculate composite score using ranks (lower is better for accrual/debt, higher for cfa/roe)
        for f in filtered:
            # Cash flow to assets.
            f.cfa = (
                f.financial_statements.cash_flow_statement.operating_cash_flow.value 
                / (f.earning_reports.basic_eps.value * f.earning_reports.basic_average_shares.value)
            )
            # Debt to assets.
            f.da = f.operation_ratios.debt_to_assets.value
            # Return on equity.
            f.roe = f.operation_ratios.roe.value
        # Sort stocks by four factors respectively.
        sorted_by_accrual = sorted(filtered, key=lambda x: accrual_by_symbol[x.symbol], reverse=True) # High score with low accrual. 
        sorted_by_cfa = sorted(filtered, key=lambda x: x.cfa)                       # High score with high CFA.
        sorted_by_da = sorted(filtered, key=lambda x: x.da, reverse=True)           # High score with low leverage.
        sorted_by_roe = sorted(filtered, key=lambda x: x.roe)                       # High score with high ROE.
        # Create dict to save the score for each stock.
        score_dict = {}
        # Assign a score to each stock according to their rank with different factors.
        for i,obj in enumerate(sorted_by_accrual):
            score_accrual = i
            score_cfa = sorted_by_cfa.index(obj)
            score_da = sorted_by_da.index(obj)
            score_roe = sorted_by_roe.index(obj)
            score = score_accrual + score_cfa + score_da + score_roe
            score_dict[obj.symbol] = score
        sorted_by_score = sorted(score_dict, key=lambda x: score_dict[x])
        # Long stocks with the top score (> 10%) and short stocks with the bottom score (< 10%).
        self._long = sorted_by_score[-int(0.10 * len(sorted_by_score)):]
        self._short = sorted_by_score[:int(0.10 * len(sorted_by_score))]
        self._previous_factors = factors_by_symbol
        return self._long + self._short

    def _rebalance(self, skip_checks=False):
        # Rebalance portfolio by allocating equal weight to long and short positions.
        if not skip_checks and not (self._long and self._short and self.time.month == 6):
            return
        long = [s for s in self._long if self.securities[s].price]
        short = [s for s in self._short if self.securities[s].price]
        targets = [PortfolioTarget(s, 0.5 / len(long)) for s in long] 
        targets += [PortfolioTarget(s, -0.5 / len(short)) for s in short]        
        self.set_holdings(targets, True)    

    def _calc_accrual(self, prev, curr):
        # Calculates the balance sheet accruals and adds the property to the fundamental object.
        delta_assets = curr.current_assets - prev.current_assets
        delta_cash = curr.cash_equivalent - prev.cash_equivalent
        delta_liabilities = curr.liabilities - prev.liabilities
        delta_debt = curr.current_debt - prev.current_debt
        delta_tax = curr.tax_payable - prev.tax_payable
        dep = curr.depreciation_amortization
        avg_total = (curr.total_assets + prev.total_assets) / 2
        # Accounts for the size difference.
        return ((delta_assets - delta_cash) - (delta_liabilities - delta_debt - delta_tax) - dep) / avg_total


class AccuralData:

    def __init__(self, fundamental):
        bs = fundamental.financial_statements.balance_sheet
        self.total_assets = bs.total_assets.value
        self.cash_equivalent = bs.cash_and_cash_equivalents.value
        self.current_debt = bs.current_debt.value
        self.tax_payable = bs.income_tax_payable.value
        self.liabilities = bs.current_liabilities.value
        self.current_assets = bs.current_assets.value
        self.depreciation_amortization = fundamental.financial_statements.income_statement.depreciation_and_amortization.value