Introduction
Value investing has been a popular strategy for centuries. An efficient way to do this is by summarizing financial ratios into a qualitative score. Financial ratios are more comparable than absolute numbers, time-invariant, and sector-invariant to some extent, making them perfect candidates to analyze the change in financial health and potential of a company or compare companies with their competitors.
Developed by Stanford accounting professor Joseph Piotroski, the Piotroski F-score is a popular tool to measure the strength of a firm's financial position. The score comprises three categories and nine sub-categories, each worth one point. Higher F-scores represent the best-value stocks. The original author suggested that a company score of eight or nine is considered a good value, and a score between zero and two points is likely a poor value. Researchers found that thresholds from 5-8 also worked, beating their corresponding benchmarks.
Method
We hypothesized that financial robustness and growth would translate to higher stock returns and provide resilience to poor markets. Our independent variable is the F-Score universe filtering, compared to buy and hold of the SPY benchmark over the last three years (7/1/2020 to 7/1/2023). We adjusted portfolio construction and execution models to mimic the market and reduce risk and friction. We study the annual compound return, Sharpe Ratio, Information Ratio, maximum drawdown, and annual standard deviation to examine the strategy's risk-adjusted profitability, robustness, and consistency.
Let's examine how we can implement an F-score strategy with QuantConnect and the MorningStar US Fundamental dataset.
Universe Selection
We excluded financially "unhealthy" to avoid penny stocks as they are more susceptible to manipulation, making the F-Score or other fundamental-data metrics inaccurate. We selected stocks with a Piotroski F-Score greater than 7 as it balanced financial strength and portfolio diversification. Extreme thresholds filter out most stocks. We did this in the fundamental universe selection model.
def select_coarse(self, coarse):
# We only want stocks with fundamental data and price > $1
filtered = [x.symbol for x in coarse if x.price > 1]
return filtered
def select_fine(self, fine):
# We use a dictionary to hold the F-Score of each stock
f_scores = {}
for f in fine:
# Calculate the Piotroski F-Score of the given stock
f_scores[f.symbol] = self.get_piotroski_f_score(f)
# Select the stocks with F-Score higher than the threshold
selected = [symbol for symbol, fscore in f_scores.items() if fscore >= self.fscore_threshold]
return selected
To obtain the Piotroski F-Score, we create the GetPiotroskiFScore method to calculate the Piotroski F-Score of each stock, with its Fine Fundamental object as input argument. It accesses the 9 sub-categories to check if the company meets the following criteria:
- A positive income
- A positive operating cash flow
- Showing increased profitability
- Income realized as cash flow
- Low long-term debt
- Able to repay debts within 1 year.
- Is fundraising
- Is profitable
- Efficiency improvements
from f_score import *
def get_piotroski_f_score(self, fine):
# initial F-Score as 0
fscore = 0
# Add up the sub-scores in different aspects
fscore += GetROAScore(fine)
fscore += GetOperatingCashFlowScore(fine)
fscore += GetROAChangeScore(fine)
fscore += GetAccrualsScore(fine)
fscore += GetLeverageScore(fine)
fscore += GetLiquidityScore(fine)
fscore += GetShareIssuedScore(fine)
fscore += GetGrossMarginScore(fine)
fscore += GetAssetTurnoverScore(fine)
return fscore
where all the sub-scores are calculated by helper methods stored in f_score.py.
def get_r_o_a_score(fine):
'''Get the Profitability - Return of Asset sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Profitability - Return of Asset sub-score'''
# Nearest ROA as current year data
roa = fine.operation_ratios.ROA.three_months
# 1 score if ROA datum exists and positive, else 0
score = 1 if roa and roa > 0 else 0
return score
def get_operating_cash_flow_score(fine):
'''Get the Profitability - Operating Cash Flow sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Profitability - Operating Cash Flow sub-score'''
# Nearest Operating Cash Flow as current year data
operating_cashflow = fine.financial_statements.cash_flow_statement.cash_flow_from_continuing_operating_activities.three_months
# 1 score if operating cash flow datum exists and positive, else 0
score = 1 if operating_cashflow and operating_cashflow > 0 else 0
return score
def get_r_o_a_change_score(fine):
'''Get the Profitability - Change in Return of Assets sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Profitability - Change in Return of Assets sub-score'''
# if current or previous year's ROA data does not exist, return 0 score
roa = fine.operation_ratios.ROA
if not roa.three_months or not roa.one_year:
return 0
# 1 score if change in ROA positive, else 0 score
score = 1 if roa.three_months > roa.one_year else 0
return score
def get_accruals_score(fine):
'''Get the Profitability - Accruals sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Profitability - Accruals sub-score'''
# Nearest Operating Cash Flow, Total Assets, ROA as current year data
operating_cashflow = fine.financial_statements.cash_flow_statement.cash_flow_from_continuing_operating_activities.three_months
total_assets = fine.financial_statements.balance_sheet.total_assets.three_months
roa = fine.operation_ratios.ROA.three_months
# 1 score if operating cash flow, total assets and ROA exists, and operating cash flow / total assets > ROA, else 0
score = 1 if operating_cashflow and total_assets and roa and operating_cashflow / total_assets > roa else 0
return score
def get_leverage_score(fine):
'''Get the Leverage, Liquidity and Source of Funds - Change in Leverage sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Leverage, Liquidity and Source of Funds - Change in Leverage sub-score'''
# if current or previous year's long term debt to equity ratio data does not exist, return 0 score
long_term_debt_ratio = fine.operation_ratios.long_term_debt_equity_ratio
if not long_term_debt_ratio.three_months or not long_term_debt_ratio.one_year:
return 0
# 1 score if long term debt ratio is lower in the current year, else 0 score
score = 1 if long_term_debt_ratio.three_months < long_term_debt_ratio.one_year else 0
return score
def get_liquidity_score(fine):
'''Get the Leverage, Liquidity and Source of Funds - Change in Liquidity sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Leverage, Liquidity and Source of Funds - Change in Liquidity sub-score'''
# if current or previous year's current ratio data does not exist, return 0 score
current_ratio = fine.operation_ratios.current_ratio
if not current_ratio.three_months or not current_ratio.one_year:
return 0
# 1 score if current ratio is higher in the current year, else 0 score
score = 1 if current_ratio.three_months > current_ratio.one_year else 0
return score
def get_share_issued_score(fine):
'''Get the Leverage, Liquidity and Source of Funds - Change in Number of Shares sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Leverage, Liquidity and Source of Funds - Change in Number of Shares sub-score'''
# if current or previous year's issued shares data does not exist, return 0 score
shares_issued = fine.financial_statements.balance_sheet.share_issued
if not shares_issued.three_months or not shares_issued.twelve_months:
return 0
# 1 score if shares issued did not increase in the current year, else 0 score
score = 1 if shares_issued.three_months <= shares_issued.twelve_months else 0
return score
def get_gross_margin_score(fine):
'''Get the Operating Efficiency - Change in Gross Margin sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Operating Efficiency - Change in Gross Margin sub-score'''
# if current or previous year's gross margin data does not exist, return 0 score
gross_margin = fine.operation_ratios.gross_margin
if not gross_margin.three_months or not gross_margin.one_year:
return 0
# 1 score if gross margin is higher in the current year, else 0 score
score = 1 if gross_margin.three_months > gross_margin.one_year else 0
return score
def get_asset_turnover_score(fine):
'''Get the Operating Efficiency - Change in Asset Turnover Ratio sub-score of Piotroski F-Score
Arg:
fine: Fine fundamental object of a stock
Return:
Operating Efficiency - Change in Asset Turnover Ratio sub-score'''
# if current or previous year's asset turnover data does not exist, return 0 score
asset_turnover = fine.operation_ratios.assets_turnover
if not asset_turnover.three_months or not asset_turnover.one_year:
return 0
# 1 score if asset turnover is higher in the current year, else 0 score
score = 1 if asset_turnover.three_months > asset_turnover.one_year else 0
return score
Portfolio Construction and Execution
We will invest in all the filtered stocks, but we carefully choose the positional sizing and order execution models to reduce risks and transaction costs. We invested the same size in every sector using the SectorWeightingPortfolioConstructionModel. We also used a SpreadExecutionModel to reduce friction costs due to large bid-ask spread in illiquid stocks.
self.set_portfolio_construction(SectorWeightingPortfolioConstructionModel())
self.set_execution(SpreadExecutionModel(0.01)) # maximum 1% spread allowed
Reality Modeling
We are unsure about the liquidity of the invested stocks, so we adapt the VolumeShareSlippageModel model with price impact on volume invested via a custom security initializer. This could allow the backtest to capture more realistic filling behavior to reflect performance closer to the actual situation.
# in Initialize
self.set_security_initializer(CustomSecurityInitializer(self))
class CustomSecurityInitializer(BrokerageModelSecurityInitializer):
def __init__(self, algorithm: QCAlgorithm) -> None:
security_seeder = FuncSecuritySeeder(
lambda symbol: algorithm.history[TradeBar](symbol, 3, Resolution.DAILY))
super().__init__(algorithm.brokerage_model, security_seeder)
def initialize(self, security):
super().initialize(security)
# We want a slippage model with price impact by order size for reality modeling
security.set_slippage_model(VolumeShareSlippageModel())
Results
Result metrics over the three years are as shown below:
Discussion
We can observe that the Sharpe Ratio and Information Ratio of the F-Score strategy are much better than that of the SPY benchmark, implying that each active selection is earning a higher risk-adjusted return. We can also observe that the poor market situation in 2022 had much less impact on the F-score portfolio, which fluctuated less and had a positive trend. This provides qualitative proof of the consistency and robustness of the strategy.
However, the 95% confidence interval of the compounded annual return of the F-Score strategy is (9.197%, 76.637%), while that of the SPY benchmark is (-3.063%, 31.807%). The overlapping 95%CI of the two portfolios showed no significant difference between their annual return. The sample size is too small to draw a high-confidence conclusion that the F-Score strategy earns better than the SPY benchmark. Higher sampling frequency and more extended sampling (backtest) window shall be adapted to draw further conclusions.
The out-performance of the proposed portfolio suggested that the Piotroski F-Score is a great tool to spot under-valued stocks that provide excess returns. The investment strategies using fundamental factor analysis and value investment are consistently working factors while quantifiable to carry out a quantitative analysis.
Nonetheless, it is certainly not a silver bullet for various market regimes. It has no prediction, valuation value, or rationale for defending a poor market. Mohr (2012) used F-score and momentum to produce a stable, positive return over the 2008 global financial crisis. Hyde (2018) created a market-neutral strategy by buying high F-score and selling low F-score stocks with equal- and beta-weighting to eliminate systemic risk.
Conclusion
This research has weakly proven the profitability, robustness, and consistency of the Piotroski F-Score filtering.
References
- Stockopedia. https://www.businessinsider.com/the-piotroski-f-score-reviewing-joseph-piotroskis-accounting-based-value-investing-screen-2011-4. Business Insider.
- Piotroski, Joseph D. (January 2002). http://www.chicagobooth.edu/~/media/FE874EE65F624AAEBD0166B1974FD74D.pdf (PDF). The University of Chicago Graduate School of Business.
- Eremenko, E. (2017). Quantitative Fundamentals. https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3262154. (April 9, 2017).
AlMoJo
Hello
That looks quite demanding in terms of compute power as I am unable to get the same backtest in less than an hour on a free account. Do you have an idea about the recomanded hardware setup to get the backtest done faster?
Thanks a lot :)
Jared Broad
Hi @AIMojo, we're working on this to make fundamental backtests 10x faster. It should be ready by mid-September.
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
Pavel Fedorov
so basically here it seems all the return is just due to trading of completely illiquid stocks… when you set decent dollarvolume criteria,,the results become horrible,,,
Louis Szeto
Hi Pavel
I would say this is expected since F-score is looking for undervalued stocks. This type of value strategy will not trade a lot, likewise for the investors invested in those stocks. If the "underdog" did not stay laid low anymore, it would likely not be undervalued. Often, the arbitrage has already being realized when the volume has been brought up. You might want to test out this hypothesis with appropriate settings and statistical tests.
Hence, the volume shall not be a selection criterion, or more aggressively, an "inverse" selector. Let us know if you find/test out anything!😊
Best
Louis
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
Fyker1
Hi everyone
The main problem with the Piotrosky F-score is inability to implement the metric from the original paper from the 2002. Main issues with implementation in QC is that here we are not able to do proper time intervals. By proper I mean ones from the paper, in general form, t comparing with t-1, this may give incorrect classification and affect the liquidity*. Though,as far as I know there was a thread on git on accessing multi-period fundamental data in the backtest environment.
*To be fair, the problem of liquidity was adressed in the original paper, where J. D. Piotroski used some screeners prior to the F-score itself(market cap, etc.).
Sanju
How can I add risk management i.e. halt trading if drawdown exceeds 10%
Michael Hofer
Very Interesting Fundamental Strategy,
I modified it to my personal preferences.
Mostly Important Changes: Cash Account Type for IB Broker, Custom Buying Power for ensuring Margin Requirements, Universe can now be filtered with max Price and maximum Amount of Filtered Stocks (which is sorted by Market Cap), added Risk Management, added option for monthly contribution and a function where you can simulate a “pull out” of initial invested money, added tax calculation for austria with automatic payment.
This attached backtest (same time tested) is the result of a more conservative Behaviour, only 5 of the best Stocks will be traded with maximum Drawdown Risk Setting of 20% for the whole Portfolio and a setted 30% of FreePortfolioValue. For this the results are really good.
I am thinking of deploying this to a Testaccount on my Broker.
Louis Szeto
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
To unlock posting to the community forums please complete at least 30% of Boot Camp.
You can continue your Boot Camp training progress from the terminal. We hope to see you in the community soon!