In partnership with

When Is the Right Time to Retire?

Determining when to retire is one of life’s biggest decisions, and the right time depends on your personal vision for the future. Have you considered what your retirement will look like, how long your money needs to last and what your expenses will be? Answering these questions is the first step toward building a successful retirement plan.

Our guide, When to Retire: A Quick and Easy Planning Guide, walks you through these critical steps. Learn ways to define your goals and align your investment strategy to meet them. If you have $1,000,000 or more saved, download your free guide to start planning for the retirement you’ve worked for.

Elite Quant Plan – 7-Day Free Trial (This Week Only)

No card needed. Cancel anytime. Zero risk.

You get immediate access to:

  • Full code from every article (including today’s HMM notebook)

  • Private GitHub repos & templates

  • All premium deep dives (3–5 per month)

  • 2 × 1-on-1 calls with me

  • One custom bot built/fixed for you

Try the entire Elite experience for 14 days — completely free.

→ Start your free trial now 👇

(Doors close in 7 days or when the post goes out of the spotlight — whichever comes first.)

See you on the inside.

👉 Upgrade Now

🔔 Limited-Time Holiday Deal: 20% Off Our Complete 2026 Playbook! 🔔

Level up before the year ends!

AlgoEdge Insights: 30+ Python-Powered Trading Strategies – The Complete 2026 Playbook

30+ battle-tested algorithmic trading strategies from the AlgoEdge Insights newsletter – fully coded in Python, backtested, and ready to deploy. Your full arsenal for dominating 2026 markets.

Special Promo: Use code DECEMBER2025 for 20% off

Valid only until December 20, 2025 — act fast!

👇 Buy Now & Save 👇

Instant access to every strategy we've shared, plus exclusive extras.

— AlgoEdge Insights Team

Premium Members – Your Full Notebook Is Ready

The complete Google Colab notebook from today’s article (with live data, full Hidden Markov Model, interactive charts, statistics, and one-click CSV export) is waiting for you.

Preview of what you’ll get:

Inside:

  • Automatic pull of 10+ years of financial statements (Income, Balance Sheet, Cash Flow) via Financial Modeling Prep API (free tier works)

  • Full calculation of classic 9-criteria Piotroski F-Score (profitability, leverage/liquidity, efficiency)

  • Year-by-year scoring (most recent year + historical trend)

  • Clean pandas DataFrame with every component visible (ROA, ΔROA, CFO > NI, ΔGross Margin, ΔAsset Turnover, debt ratio improvement, current ratio, share issuance, etc.)

  • Stacked bar chart showing which of the 9 signals fired each year + total F-Score label on top

  • Overlay of historical stock price (via yfinance) on secondary axis — see how F-Score evolution aligned with price moves

  • One-line ticker change — works on any public company (MSFT, AAPL, TSLA, GOOGL, value stocks like GM, F, XOM, international names, etc.)

  • Bonus: defensive code that won't crash on missing data + printed diagnostics for each criterion

Free readers – you already got the full breakdown and visuals in the article. Paid members – you get the actual tool.

Not upgraded yet? Fix that in 10 seconds here👇

Google Collab Notebook With Full Code Is Available In the End Of The Article Behind The Paywall 👇 (For Paid Subs Only)

he Piotroski F-Score helps investors identify financially strong companies that may be overlooked by the market due to low market-to-book ratios.

Developed by Joseph Piotroski (2000), the F-Score is a nine-point score system which evaluates companies based on multiple dimensions — profitability, leverage, liquidity, and operational efficiency.

It offers the ability to differentiate between value stocks that are genuinely undervalued and those that are financially weak. This guide aims to provide a step-by-step implementation of the Piotroski F-Score.

We will begin by discussing the foundations of the F-Score in the context of value investing. Next, we’ll retrieve the historical financial statement data from the Financial Modeling Prep API.

The final result will be the plot below. A time series of the F-Score as stacked bar chart showing the calculated values for each variable.

Investors see ANOTHER return on Masterworks (!!!)

That’s 3 sales this quarter. 26 sales total. 

And the performance?

14.6%, 17.6%, and 17.8% → The three most representative annualized net returns.
(See all 26 at Masterworks.com)

Masterworks is the biggest platform for investing in an asset class that hasn’t moved in lockstep with the S&P 500 since ‘95.

In fact, the market segment they target outpaced the S&P overall in that time frame.*

Not private equity or real estate… It’s contemporary and post war art. Crazy, right? 

Masterworks investors are typically high net worth, but the point is that you don’t need to be a capital-B BILLIONAIRE to invest in high-caliber art anymore.

Banksy. Basquiat. Picasso and more. 

80+ of the world’s most attractive artists have been featured.

  • 511+ artworks offered

  • $67.5mm paid out as of December 2025

  • $2.3mm+ average offering size

Looking to update your investment portfolio before 2026?

*Masterworks data. Investing involves risk. Past performance not indicative of future returns. Reg A disclosures at masterworks.com/cd

1. Value Investing with Piotroski’s F-Score

Piotroski’s contribution in his 2000 paper, “Value Investing: The Use of Historical Financial Statement Information to Separate Winners from Losers”, explored a systematic way to refine deep-value stock selection.

He built on Graham and Dodd’s principle that certain low price-to-book (P/B) stocks remain undervalued for extended periods (the core principle of value investing).

Specifically, Piotroski focuses on firms with high book-to-market ratios:

Firms in the upper range of B/M appear undervalued, however many are cheap for a justifiable reason. For example, while Fama and French (1992) show that high B/M stocks generally earn higher returns, Lakonishok et al. (1994) highlight that not all of such firms recover.

Piotroski’s goal is to filter out only those with improving fundamentals. A nine binary signals is desgined to form the Piotroski F-Score:

Here indicator for the ith variable is 1 if a signal is favorable, else 0. We’ll discuss each of the variables in the implementation.

These signals fall into four major buckets:

  • Profitability: Uses net income, return on assets, and operating cash flow to check if performance is improving.

  • Leverage and Liquidity: Examines changes in long-term debt and current ratio to assess debt management and short-term strength.

  • Equity Financing: Screens out companies that show large increases in share issuance.

  • Operational Efficiency: Looks at shifts in gross margin and asset turnover to see if the firm is using resources better.

Piotroski’s score builds on the hypothesis that value stocks are mispriced due to investor behavioral biases and information asymmetry.

Specifically, markets often underreact to granular financial statement data. Investors may overlook subtle improvements in profitability or margin expansions. This is especially true when headlines are negative.

Piotroski’s (2000) backtests reveal that high-F-Score stocks within the high-B/M universe outperform those with low ⁣F-Scores. He records a spread in excess returns that persists after controlling for size and momentum.

Several studies build upon or validate Piotroski’s findings:

  • Piotroski and So (2012): Expanded the original framework by incorporating market sentiment indicators. The authors show that combining fundamentals with sentiment improves returns.

  • Chen and Zhang (2007): Tested Piotroski’s model in international markets and confirmed the methodology across geographies.

  • Mohanty et al. (2021): Applied machine learning techniques to enhance Piotroski’s framework. The authors emphasized non-linear relationships between financial metrics and stock returns.

2. Data Acquisition

The data used for this analysis is obtained from the Financial Modeling Prep (FMP) API.

The FMP API offers access to income statements, balance sheets, cash flow statements, and market data across time, which are needed for computing the dimensions of the Piotroski Score. Get started with 15% discount:

3. Python Implementation of Piotroski F-Score

Let’s start retrieving the data required for each variable in each of the groups in the Piotroski framework.

We’ll discuss the theoretical and practical foundations for each variable as well. This will help analyst adjust their due diligence as desired.

If You Could Be Earlier Than 85% of the Market?

Most read the move after it runs. The top 250K start before the bell.

Elite Trade Club turns noise into a five-minute plan—what’s moving, why it matters, and the stocks to watch now. Miss it and you chase.

Catch it and you decide.

By joining, you’ll receive Elite Trade Club emails and select partner insights. See Privacy Policy.

3.1 Profitability Metrics

The first group focuses on profitability — profitable companies can fund their own growth, generate value for shareholders, and exhibit lower risk.

3.1.1 Positive Return on Assets (ROA)

One of the first signals is that Return on Assets is positive. This indicates that the company is earning income relative to its asset base and managing its assets in a profitable way.

Specifically, ROA measures how efficiently a company uses its assets to generate net income. The common formula is:

Using the average of the current and previous total assets gives a smoother view of how effectively the company leveraged its resources during the year.

Researchers and analysts associate stronger ROA with effective asset utilization and potential long-term operational competitiveness.

We define the following function to calculate this indicator programmatically:

  1. The function fetches net income from the income statement and total assets from the balance sheet for the latest two annual reports.

  2. It calculates the average total assets and divides net income by that figure.

  3. If the result is greater than zero, it returns a score of 1; otherwise, 0.

  4. It prints the core data (net income, average assets, ROA) for transparency.

# Dimension 1: Positive Return on Assets
def dimension_1_positive_roa(symbol, years_back=1):

    limit_needed = years_back + 1
    
    income_url = (
        f"https://financialmodelingprep.com/api/v3/income-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    balance_url = (
        f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    
    income_data = requests.get(income_url).json()
    balance_data = requests.get(balance_url).json()
    
    if len(income_data) < limit_needed or len(balance_data) < limit_needed:
        print(f"[Dimension 1] Not enough data for {years_back} year(s).")
        return []
    
    results = []
    for i in range(years_back):
        current_income = income_data[i]
        current_balance = balance_data[i]
        
        year_or_date = current_income.get("calendarYear") or current_income.get("date", f"N/A_{i}")
        
        net_income_current = current_income.get("netIncome", 0)
        ta_current = current_balance.get("totalAssets", 0)
        if i+1 < len(balance_data):
            ta_previous = balance_data[i+1].get("totalAssets", 0)
        else:
            ta_previous = 0
        
        avg_assets = (ta_current + ta_previous) / 2 if ta_previous else 0
        roa_current = net_income_current / avg_assets if avg_assets else 0
        score = 1 if roa_current > 0 else 0
        
        print(
            f"Dimension 1 (Positive ROA) | Year={year_or_date}: {score} => "
            f"NetIncome={net_income_current}, "
            f"AvgAssets={int(avg_assets)}, "
            f"ROA={roa_current:.4f}"
        )
        
        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

We implement the function for Microsoft with the line of code below. years_back=1 means that we calculate the indicator for the latest year.

If years_back=10, it would calculate the indicator for each year, 10 years back.

dimension_1_positive_roa("MSFT", years_back=1)

(Positive ROA): 1 => NetIncome=88b, AvgAssets=462b, ROA=0.19

ROA is positive for MSFT. Therefore, the first indicator is 1. Specifically, ROA is ~19%.

This implies that for every dollar in assets, MSFT generates ~19 cents in net income.

3.1.2 Positive Operating Cash Flow (CFO)

The next indicator is a positive ‘Operating Cash Flow’. CFO reflects the cash a company generates from its core business operations.

It signals sustainable operations and ability to meet obligations without external financing.

Piotroski uses positive CFO as a key indicator because it directly measures a firm’s ability to generate cash.

Unlike ROA, it is less prone to accounting manipulations (e.g. influences of non-cash items like depreciation).

The indicator requires that:

Positive CFO ensures that the company’s operations produce enough cash to cover its operating expenses and future growth.

The following function is developed to programmatically determine the indicator. The mecanics are similar to the previous function. We use operatingCashFlow variable to determine if CFO is positive for the year

# Dimension 2: Positive Operating Cash Flow
def dimension_2_positive_cfo(symbol, years_back=1):

    # For CFO, we only need 'years_back' data points, not +1, 
    # because we don't compare current to previous. 
    # However, for consistency, let's keep it at years_back to show the "year" of each record.
    
    limit_needed = years_back
    
    cf_url = (
        f"https://financialmodelingprep.com/api/v3/cash-flow-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    cf_data = requests.get(cf_url).json()

    if len(cf_data) < limit_needed:
        print(f"[Dimension 2] Not enough data for {years_back} year(s).")
        return []
    
    results = []
    for i in range(years_back):
        record = cf_data[i]
        year_or_date = record.get("calendarYear") or record.get("date", f"N/A_{i}")
        
        cfo_current = record.get("operatingCashFlow", 0)
        score = 1 if cfo_current > 0 else 0
        
        print(
            f"Dimension 2 (Positive CFO) | Year={year_or_date}: {score} => CFO={cfo_current}"
        )
        
        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

We call the function to retrieve the results for MSFT:

dimension_2_positive_cfo("MSFT", years_back=1)

(Positive CFO): 1 => CFO=118.5b

This means that:

  • Microsoft’s CFO is positive and therefore an indicator of 1 is given

  • Microsoft generated $118.5 billion in cash from its core operations, like selling software, cloud services, and devices. This figure excludes financing activities (e.g. borrowing) or investing activities (e.g. acquisitions).

  • With $118.5 billion in CFO, Microsoft can cover its operating expenses, reinvest in growth, and pay dividends without depending on external financing (debt or equity issuance).

3.1.3 Improvement in ROA Year-Over-Year

Piotroski’s framework values year-over-year improvement in ‘Return on Assets’ as a key profitability signal. While a positive ROA (as seen in variable 1) indicates operational efficiency, an improving ROA shows that the company is increasing its profitability relative to its assets over time.

This ensures that the company’s financial health is not static but progressively improving. Piotroski included this metric to focus on companies that demonstrate upward trends in operational efficiency. This is more attractive in value investing.

Formally, improvement in ROA is calculated as:

ROA itself is defined as:

This metric compares the company’s profitability relative to its asset base for the current and previous years.

Here’s the code that calculates this indicator over time:

# Dimension 3: Improvement in ROA
def dimension_3_improved_roa(symbol, years_back=1):
    """
    We need years_back+1 data points, because we compare current ROA to previous ROA.
    """
    limit_needed = years_back + 1

    income_url = (
        f"https://financialmodelingprep.com/api/v3/income-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    balance_url = (
        f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    income_data = requests.get(income_url).json()
    balance_data = requests.get(balance_url).json()

    if len(income_data) < limit_needed or len(balance_data) < limit_needed:
        print(f"[Dimension 3] Not enough data for {years_back} year(s).")
        return []
    
    results = []
    for i in range(years_back):
        current_income = income_data[i]
        current_balance = balance_data[i]
        
        # Attempt to fetch year
        year_or_date = current_income.get("calendarYear") or current_income.get("date", f"N/A_{i}")
        
        net_income_current = current_income.get("netIncome", 0)
        ta_current = current_balance.get("totalAssets", 0)
        
        # For 'previous' data, we need i+1
        if i+1 < len(income_data):
            net_income_previous = income_data[i+1].get("netIncome", 0)
            ta_previous = balance_data[i+1].get("totalAssets", 0)
        else:
            net_income_previous = 0
            ta_previous = 0
        
        # Compute ROA_current
        avg_current = (ta_current + ta_previous) / 2 if ta_previous else 0
        roa_current = net_income_current / avg_current if avg_current else 0
        
        # Now we want the year before that, but let's do a simpler fallback
        # If i+2 is available, we can refine, but let's keep it consistent:
        # We'll compare i to i+1.
        
        # Compute ROA_previous
        # We do not go for i+2 in this code to keep it straightforward.
        net_income_prev = net_income_previous
        # We'll need an additional data point for the 'previous' previous
        # But let's approximate the same approach:
        if i+2 < len(balance_data):
            ta_prev_year = balance_data[i+1].get("totalAssets", 0)
            ta_prev_year_before = balance_data[i+2].get("totalAssets", 0)
            avg_prev = (ta_prev_year + ta_prev_year_before) / 2 if ta_prev_year_before else ta_prev_year
            roa_previous = net_income_prev / avg_prev if avg_prev else 0
        else:
            roa_previous = net_income_prev / ta_previous if ta_previous else 0
        
        score = 1 if roa_current > roa_previous else 0
        
        print(
            f"Dimension 3 (ROA Improvement) | Year={year_or_date}: {score} => "
            f"ROA_current={roa_current:.4f}, ROA_previous={roa_previous:.4f}"
        )

        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

The results for MSFT are the following:

dimension_3_improved_roa("MSFT", years_back=1)

(ROA Improvement): 1 => ROA_current=0.1907, ROA_previous=0.1863

The score of 1 indicates Microsoft’s ROA improved year-over-year. This meets Piotroski’s criteria for operational improvement.

Moreover, Microsoft’s ability to increase profitability relative to its assets shows a positive trend in asset efficiency.

3.1.4 CFO Exceeds Net Income

Piotroski considers CFO exceeding net income as an indicator of earnings quality. This dimension addresses whether the company’s reported profits (net income) are backed by real cash generation.

A higher CFO compared to net income suggests that the company is not relying on accrual accounting adjustments, which can sometimes inflate earnings as “paper profits”.

Formally, this dimension checks that:

If CFO is greater than net income, the company scores 1; otherwise, it scores 0.

Positive cash flows provide liquidity to fund operations, pay debts, or reinvest, even if net income fluctuates.

Here’s the code that calculates this indicator:

# Dimension 4: CFO Exceeds Net Income
def dimension_4_cfo_exceeds_net_income(symbol, years_back=1):
    """
    We only need years_back data points from both statements, not +1,
    because we don't compare to the previous year's CFO/NI. 
    We just compare CFO vs. NI in the same year.
    """
    limit_needed = years_back
    
    cf_url = (
        f"https://financialmodelingprep.com/api/v3/cash-flow-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    income_url = (
        f"https://financialmodelingprep.com/api/v3/income-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )

    cf_data = requests.get(cf_url).json()
    income_data = requests.get(income_url).json()

    if len(cf_data) < limit_needed or len(income_data) < limit_needed:
        print(f"[Dimension 4] Not enough data for {years_back} year(s).")
        return []

    results = []
    for i in range(years_back):
        c = cf_data[i]
        inc = income_data[i]
        
        year_or_date = c.get("calendarYear") or c.get("date", f"N/A_{i}")
        
        cfo_current = c.get("operatingCashFlow", 0)
        net_income_current = inc.get("netIncome", 0)
        score = 1 if cfo_current > net_income_current else 0

        print(
            f"Dimension 4 (CFO > Net Income) | Year={year_or_date}: {score} => "
            f"CFO={cfo_current}, NetIncome={net_income_current}"
        )

        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

The results are the following:

dimension_4_cfo_exceeds_net_income("MSFT", years_back=1)

4 (CFO > Net Income): 1 => CFO=118.5b, NetIncome=88.136b

For Microsoft, CFO exceeds net income, which meets Piotroski’s criteria for high-quality earnings.

Investors can be more confident in the sustainability of earnings, as the company generates enough cash to back its reported profits.

3.2 Leverage, Liquidity, and Source of Funds

This category checks how the company manages its debts, short-term obligations, and equity financing.

Companies scoring well here demonstrate strong balance sheet management, which reduces the chance of financial distress or shareholder value erosion.

3.2.1 Decrease in Long-Term Debt Ratio

Piotroski emphasizes a decline in the long-term debt ratio as an indicator of reduced financial risk. Lower debt levels signify improved leverage management. This enhances the company’s financial flexibility.

Piotroski filters for companies that are actively deleveraging or maintaining manageable debt levels. This is especially key for distressed or undervalued firms.

This dimension evaluates whether the company’s long-term debt as a percentage of total assets has decreased year-over-year. The formula for this ratio is:

A score of 1 is assigned if:

Otherwise, the score is 0.

Why It Matters

  • Lower Financial Risk: A declining long-term debt ratio reduces the company’s reliance on debt, lowering interest obligations and default risks.

  • Improved Financial Health: Companies with lower leverage are better positioned to weather downturns and invest in growth opportunities.

Here’s the code for this dimension:

# Dimension 5: Decrease in Long-Term Debt Ratio
def dimension_5_lower_leverage(symbol, years_back=1):
    """
    This dimension compares the current year to the previous year => need years_back+1 data.
    """
    limit_needed = years_back + 1
    
    bal_url = (
        f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    balance_data = requests.get(bal_url).json()

    if len(balance_data) < limit_needed:
        print(f"[Dimension 5] Not enough data for {years_back} year(s).")
        return []

    results = []
    for i in range(years_back):
        current_bal = balance_data[i]
        year_or_date = current_bal.get("calendarYear") or current_bal.get("date", f"N/A_{i}")

        ltd_current = current_bal.get("longTermDebt", 0)
        ta_current = current_bal.get("totalAssets", 0)

        if i+1 < len(balance_data):
            ltd_previous = balance_data[i+1].get("longTermDebt", 0)
            ta_previous = balance_data[i+1].get("totalAssets", 0)
        else:
            ltd_previous = 0
            ta_previous = 0

        ratio_current = ltd_current / ta_current if ta_current else 0
        ratio_previous = ltd_previous / ta_previous if ta_previous else 0

        score = 1 if ratio_current < ratio_previous else 0
        print(
            f"Dimension 5 (Lower Debt Ratio) | Year={year_or_date}: {score} => "
            f"DebtRatio_current={ratio_current:.4f}, DebtRatio_previous={ratio_previous:.4f}"
        )

        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

The results show that:

dimension_5_lower_leverage("MSFT", years_back=1)

5 (Lower Debt Ratio): 1 => DebtRatio_current=0.1620, DebtRatio_prev=0.1713

The score of 1 indicates Microsoft reduced its long-term debt ratio, which meets Piotroski’s criteria for improved leverage.

Specifically, Microsoft’s leverage has decreased, from 11.76% of assets to 9.56%.

3.2.2 Improvement in Current Ratio Year-Over-Year

The current ratio measures a company’s ability to cover its short-term obligations with its short-term assets.

Piotroski uses this indicator to assess whether a company’s liquidity position has improved over the past year.

The current ratio is calculated as:

A higher current ratio is preferable.

The year-over-year improvement is evaluated as:

Here’s the code for this indicator:

# Dimension 6: Improvement in Current Ratio
def dimension_6_higher_current_ratio(symbol, years_back=1):
    """
    Need years_back+1 data to compare current ratio vs previous year.
    """
    limit_needed = years_back + 1
    
    bal_url = (
        f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    balance_data = requests.get(bal_url).json()

    if len(balance_data) < limit_needed:
        print(f"[Dimension 6] Not enough data for {years_back} year(s).")
        return []

    results = []
    for i in range(years_back):
        current_bal = balance_data[i]
        year_or_date = current_bal.get("calendarYear") or current_bal.get("date", f"N/A_{i}")

        ca_current = current_bal.get("totalCurrentAssets", 0)
        cl_current = current_bal.get("totalCurrentLiabilities", 0)
        cr_current = ca_current / cl_current if cl_current else 0

        if i+1 < len(balance_data):
            ca_previous = balance_data[i+1].get("totalCurrentAssets", 0)
            cl_previous = balance_data[i+1].get("totalCurrentLiabilities", 0)
            cr_previous = ca_previous / cl_previous if cl_previous else 0
        else:
            cr_previous = 0

        score = 1 if cr_current > cr_previous else 0
        print(
            f"Dimension 6 (Higher Current Ratio) | Year={year_or_date}: {score} => "
            f"CR_current={cr_current:.4f}, CR_previous={cr_previous:.4f}"
        )

        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

The results for Microsoft shows the following:

dimension_6_higher_current_ratio("MSFT", years_back=1)

6 (Higher Current Ratio): 0 => CR_current=1.2750, CR_prev=1.7692

On this dimension, Microsoft gets a score of 0. Microsoft’s current ratio decreased from 1.76 to 1.27, which indicates weaker short-term liquidity.

3.2.3 No New Shares Issued

Next, Piotroski evaluates whether the company has refrained from issuing new shares.

Issuing new shares dilutes ownership and signals potential financial distress if done to raise capital.

Companies that either maintain or reduce their outstanding shares indicate strong stability and management confidence.

Furthermore, avoiding shareholder value dilution is particularly important for value investors focused on ownership consistency.

Formally, the condition checks if the weighted average shares outstanding for the current year is less than or equal to the previous year:

WASO is the average number of shares outstanding, accounting for any changes during the year (e.g., buybacks or new issuances).

Why It Matters:

  • Shareholder Value: Issuing new shares can dilute existing ownership and reduce earnings per share.

  • Financial Stability: Companies issuing shares may signal financial instability, whereas maintaining or reducing shares shows management confidence in internal resources (see financing pecking order for more details).

# Dimension 7: No New Shares Issued
def dimension_7_no_new_shares(symbol, years_back=1):
    """
    Need years_back+1 for current vs previous
    """
    limit_needed = years_back + 1
    
    inc_url = (
        f"https://financialmodelingprep.com/api/v3/income-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    inc_data = requests.get(inc_url).json()

    if len(inc_data) < limit_needed:
        print(f"[Dimension 7] Not enough data for {years_back} year(s).")
        return []

    results = []
    for i in range(years_back):
        current_inc = inc_data[i]
        year_or_date = current_inc.get("calendarYear") or current_inc.get("date", f"N/A_{i}")

        shares_current = current_inc.get("weightedAverageShsOut", 0)
        if i+1 < len(inc_data):
            shares_previous = inc_data[i+1].get("weightedAverageShsOut", 0)
        else:
            shares_previous = 0

        score = 1 if shares_current <= shares_previous else 0
        print(
            f"Dimension 7 (No New Shares) | Year={year_or_date}: {score} => "
            f"Shares_current={shares_current}, Shares_previous={shares_previous}"
        )
        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

We implement the function for Microsoft:

dimension_7_no_new_shares("MSFT", years_back=1)

7 (No New Shares): 1 => Shares_current=7431000000, Shares_prev=7446000000

Microsoft’s shares decreased from 7.44 billion to 7.43 billion. This indicates a share buyback, which benefits shareholders by increasing their ownership stake and boosting EPS.

3.3 Operational Efficiency Metrics

The last two metrics check whether the firm is becoming more efficient at both:

  1. Gross Margin: Managing product/service costs or commanding premium pricing, thus increasing the fraction of revenue left after direct costs.

  2. Asset Turnover: Generating more revenue per dollar of assets (plant, equipment, intangible assets, etc.).

3.3.1 Improvement in Gross Margin Year-Over-Year

Piotroski includes gross margin improvement to assess a company’s operational efficiency. Gross margin represents the proportion of revenue that remains after accounting for the cost of goods sold (e.g. procurement and labor).

An increase in gross margin indicates better management of production or procurement costs, which leads to improved profitability.

This metric helps to identify companies with improving operational capabilities and cost management strategies. This is especially true in competitive or challenging market conditions.

Formally, Gross margin is calculated as:

Year-over-year improvement indicator is evaluated as:

Here’s the code to calculate this indicator programmatically:

# Dimension 8: Improvement in Gross Margin
def dimension_8_improved_gross_margin(symbol, years_back=1):
    """
    Need years_back+1 because we compare GM current vs GM previous.
    """
    limit_needed = years_back + 1
    
    inc_url = (
        f"https://financialmodelingprep.com/api/v3/income-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    inc_data = requests.get(inc_url).json()

    if len(inc_data) < limit_needed:
        print(f"[Dimension 8] Not enough data for {years_back} year(s).")
        return []

    results = []
    for i in range(years_back):
        current_inc = inc_data[i]
        year_or_date = current_inc.get("calendarYear") or current_inc.get("date", f"N/A_{i}")

        rev_current = current_inc.get("revenue", 0)
        gp_current = current_inc.get("grossProfit", 0)
        gm_current = gp_current / rev_current if rev_current else 0

        if i+1 < len(inc_data):
            rev_previous = inc_data[i+1].get("revenue", 0)
            gp_previous = inc_data[i+1].get("grossProfit", 0)
            gm_previous = gp_previous / rev_previous if rev_previous else 0
        else:
            gm_previous = 0

        score = 1 if gm_current > gm_previous else 0
        print(
            f"Dimension 8 (Gross Margin Up) | Year={year_or_date}: {score} => "
            f"GM_current={gm_current:.4f}, GM_previous={gm_previous:.4f}"
        )
        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

The results for MSFT are the following:

dimension_8_improved_gross_margin("MSFT", years_back=1)

8 (Gross Margin Up): 1 => GM_current=0.6976, GM_prev=0.6892

Microsoft increased its gross margin, therefore, it gets an indicator of 1 for this dimension. This reflects improved cost management or pricing strategy. It also suggests the company is generating more profit per dollar of revenue.

A gross margin near 70% is exceptionally high and indicates that Microsoft is likely able to maintain premium pricing for its products and services, such as for Azure, software licenses, and devices.

An improvement year-over-year suggests either a reduction in discounts or an increased willingness of customers to pay a premium price for Microsoft products.

Furthermore, Microsoft’s size and market dominance likely allow it to achieve economies of scale, which lowers per-unit costs as revenue grows.

3.3.2 Improvement in Asset Turnover Ratio (ATO) Year-Over-Year

The asset turnover ratio measures how efficiently a company utilizes its assets to generate revenue.

An improving ATO indicates that the company is effectively generating more revenue for every dollar invested in assets.

The ATO is calculated as:

The year-over-year improvement indicator is assessed as:

Why It Matters:

  1. Operational Efficiency: A higher ATO indicates better utilization of assets to generate revenue.

  2. Revenue Growth: An improving ATO suggests that revenue is growing faster relative to asset investment.

  3. Cost Management: If ATO decreases, it might indicate inefficiencies in asset usage or a mismatch between asset growth and revenue generation.

# Dimension 9: Improvement in Asset Turnover
def dimension_9_improved_ato(symbol, years_back=1):
    """
    Need years_back+1 to compare current ATO vs previous ATO.
    """
    limit_needed = years_back + 1
    
    inc_url = (
        f"https://financialmodelingprep.com/api/v3/income-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )
    bal_url = (
        f"https://financialmodelingprep.com/api/v3/balance-sheet-statement/{symbol}"
        f"?limit={limit_needed}&period=annual&apikey={API_KEY}"
    )

    inc_data = requests.get(inc_url).json()
    bal_data = requests.get(bal_url).json()

    if len(inc_data) < limit_needed or len(bal_data) < limit_needed:
        print(f"[Dimension 9] Not enough data for {years_back} year(s).")
        return []

    results = []
    for i in range(years_back):
        inc_current = inc_data[i]
        bal_current = bal_data[i]
        year_or_date = inc_current.get("calendarYear") or inc_current.get("date", f"N/A_{i}")

        rev_current = inc_current.get("revenue", 0)
        ta_current = bal_current.get("totalAssets", 0)

        if i+1 < len(bal_data):
            ta_prev_for_cur = bal_data[i+1].get("totalAssets", 0)
        else:
            ta_prev_for_cur = 0

        avg_assets_current = (ta_current + ta_prev_for_cur) / 2 if ta_prev_for_cur else 0
        ato_current = rev_current / avg_assets_current if avg_assets_current else 0

        # Compare to previous year ATO
        if i+1 < len(inc_data) and i+2 < len(bal_data):
            # More advanced approach: compare i to i+1 and i+2, but let's keep it simpler:
            rev_previous = inc_data[i+1].get("revenue", 0)
            ta_previous = bal_data[i+1].get("totalAssets", 0)
            ato_previous = rev_previous / ta_previous if ta_previous else 0
        else:
            # Fallback
            rev_previous = 0
            ato_previous = 0

        score = 1 if ato_current > ato_previous else 0
        print(
            f"Dimension 9 (Asset Turnover Up) | Year={year_or_date}: {score} => "
            f"ATO_current={ato_current:.4f}, ATO_previous={ato_previous:.4f}"
        )
        results.append({
            "year": str(year_or_date),
            "score": score
        })
    return results

We implement it to get the following results:

dimension_9_improved_ato("MSFT", years_back=1)

9 (Asset Turnover Up): 0 => ATO_current=0.5305, ATO_prev=0.5456

The score of 0 indicates that Microsoft’s asset turnover ratio did not improve year-over-year.

Microsoft generated 0.53 dollars of revenue for every dollar of assets in the current year, slightly less than the 0.54 dollars in the previous year.

This suggests that either Microsoft’s asset base grew faster than revenue or operational efficiency slightly declined.

3.4 End-to-End Implementation

Finally, we build an aggregator function to sum the results of the individual functions.

The aggregator function also stores the metrics over time in dataframe for further analysis:

# Aggregator Over Multiple Years -> DataFrame
def calculate_piotroski_scores_over_time(symbol, years_back=5):
    """
    Calls each dimension function with `years_back` for the given symbol.
    Combines all dimension scores for each 'year index' into a single DataFrame row.

    The DataFrame has columns:
      ["year", "dim1_roa", "dim2_cfo", ..., "dim9_ato", "total_score"]

    The row at index=0 corresponds to the most recent year.
    """
    # 1) Collect dimension data
    d1_list = dimension_1_positive_roa(symbol, years_back)
    d2_list = dimension_2_positive_cfo(symbol, years_back)
    d3_list = dimension_3_improved_roa(symbol, years_back)
    d4_list = dimension_4_cfo_exceeds_net_income(symbol, years_back)
    d5_list = dimension_5_lower_leverage(symbol, years_back)
    d6_list = dimension_6_higher_current_ratio(symbol, years_back)
    d7_list = dimension_7_no_new_shares(symbol, years_back)
    d8_list = dimension_8_improved_gross_margin(symbol, years_back)
    d9_list = dimension_9_improved_ato(symbol, years_back)

    # 2) Prepare rows. We assume each list has length `years_back` or is empty if data is missing.
    rows = []
    for i in range(years_back):
        # Get the year from dimension_1 if available, else fallback
        if i < len(d1_list):
            year_str = d1_list[i]["year"]
        else:
            year_str = f"N/A_{i}"

        # Safely fetch each dimension score
        dim1 = d1_list[i]["score"] if i < len(d1_list) else 0
        dim2 = d2_list[i]["score"] if i < len(d2_list) else 0
        dim3 = d3_list[i]["score"] if i < len(d3_list) else 0
        dim4 = d4_list[i]["score"] if i < len(d4_list) else 0
        dim5 = d5_list[i]["score"] if i < len(d5_list) else 0
        dim6 = d6_list[i]["score"] if i < len(d6_list) else 0
        dim7 = d7_list[i]["score"] if i < len(d7_list) else 0
        dim8 = d8_list[i]["score"] if i < len(d8_list) else 0
        dim9 = d9_list[i]["score"] if i < len(d9_list) else 0

        total_score = sum([dim1, dim2, dim3, dim4, dim5, dim6, dim7, dim8, dim9])
        
        row = {
            "year": year_str,
            "dim1_roa": dim1,
            "dim2_cfo": dim2,
            "dim3_roa_improvement": dim3,
            "dim4_cfo_over_ni": dim4,
            "dim5_lower_debt_ratio": dim5,
            "dim6_higher_current_ratio": dim6,
            "dim7_no_new_shares": dim7,
            "dim8_gross_margin_up": dim8,
            "dim9_asset_turnover_up": dim9,
            "total_score": total_score
        }
        rows.append(row)
    
    df = pd.DataFrame(rows)
    print("\n============== Piotroski Scores DataFrame ==============")
    print(df)
    print("========================================================\n")
    
    return df

The results look as follows:

symbol = "MSFT"
calculate_piotroski_score(symbol)
Dimension 1 (Positive ROA) | Year=2024: 1 => NetIncome=88136000000, AvgAssets=462069500000, ROA=0.1907
Dimension 2 (Positive CFO) | Year=2024: 1 => CFO=118548000000
Dimension 3 (ROA Improvement) | Year=2024: 1 => ROA_current=0.1907, ROA_previous=0.1756
Dimension 4 (CFO > Net Income) | Year=2024: 1 => CFO=118548000000, NetIncome=88136000000
Dimension 5 (Lower Debt Ratio) | Year=2024: 1 => DebtRatio_current=0.1620, DebtRatio_previous=0.1713
Dimension 6 (Higher Current Ratio) | Year=2024: 0 => CR_current=1.2750, CR_previous=1.7692
Dimension 7 (No New Shares) | Year=2024: 1 => Shares_current=7431000000, Shares_previous=7446000000
Dimension 8 (Gross Margin Up) | Year=2024: 1 => GM_current=0.6976, GM_previous=0.6892
Dimension 9 (Asset Turnover Up) | Year=2024: 1 => ATO_current=0.5305, ATO_previous=0.0000
=======================================
Final Piotroski Score for MSFT = 8
=======================================

Overall, Microsoft scored 8 out of 9 for the Piotroski F-score. Microsoft is therefore a financially stable, healthy and highly profitable company.

The slight drop in liquidity and asset turnover highlights minor areas for improvement.

3.5 Piotroski Over Time Plot

Using the functions above, we could explore trends over time in Piotroski F-score for Microsoft.

3.5.1 Data Over Time

First, we store the results in dataframe called df_scores

df_scores = calculate_piotroski_scores_over_time("MSFT", years_back=10)
df_scores

3.5.2 Fetch Stock Prices for Each Year

Next, we enrich the df_scores dataframe with stock price data for the calendar year:

# Fetch stock price and merge into df_scores
def fetch_stock_prices_for_years(symbol, df_scores):
    """
    Pull the last trading day's close price of that calendar year via yfinance
    """
    import pandas as pd
    from datetime import datetime

    # Convert the 'year' to int
    df_scores["year_int"] = df_scores["year"].astype(int)
    min_year = df_scores["year_int"].min()
    max_year = df_scores["year_int"].max()

    # Download daily data from Jan 1 of min_year to Dec 31 of max_year
    start_date = f"{min_year}-01-01"
    end_date = f"{max_year}-12-31"
    ticker = yf.Ticker(symbol)
    hist = ticker.history(start=start_date, end=end_date)

    # For each year in df_scores, find last trading day's 'Close'
    year_to_price = {}
    for y in df_scores["year_int"].unique():
        # If there's data for year y
        data_y = hist.loc[str(y)]
        if data_y.empty:
            year_to_price[y] = None
        else:
            last_close = data_y["Close"].iloc[-1]
            year_to_price[y] = float(f"{last_close:.2f}")  # round to 2 decimals

    # Map back into df_scores
    df_scores["stock_price"] = df_scores["year_int"].map(year_to_price)
    return df_scores


symbol = "MSFT"

# 1) Add stock prices
df_scores = fetch_stock_prices_for_years(symbol, df_scores)

3.5.3 Plot Piotroski F-score over time

We then plot the final results in the enricheddf_scores dataframe:


# Plot stacked bars + total_score label + stock price line
# Define dimension columns
dim_cols = [
    "dim1_roa", "dim2_cfo", "dim3_roa_improvement", 
    "dim4_cfo_over_ni", "dim5_lower_debt_ratio", 
    "dim6_higher_current_ratio", "dim7_no_new_shares", 
    "dim8_gross_margin_up", "dim9_asset_turnover_up"
]

# 1) Set 'year' as index and create a copy for plotting
df_plot = df_scores.set_index("year").copy()

# 2) Ensure dimension columns are integers
df_plot[dim_cols] = df_plot[dim_cols].astype(int)

# 3) Sort index ascending so older years are on the left
df_plot.sort_index(ascending=True, inplace=True)

# 4) Create the figure and main axis
fig, ax = plt.subplots(figsize=(20, 6))

# 5) Plot stacked bar for dimension columns
df_plot[dim_cols].plot(kind="bar", stacked=True, ax=ax, colormap="tab20")

ax.set_ylabel("Dimension Scores (Stacked)")
ax.set_title(f"Piotroski Dimensions for {symbol} + Stock Price")

# 6) Move legend outside the chart
ax.legend(bbox_to_anchor=(1.10, 1), loc="upper left", borderaxespad=0)

# 7) Annotate each bar segment (the 1's)
for container_idx, container in enumerate(ax.containers):
    for bar in container:
        if bar.get_height() > 0:  # It's a "1"
            ax.text(
                bar.get_x() + bar.get_width() / 2,
                bar.get_y() + bar.get_height() / 2,
                f"{int(bar.get_height())}",
                ha="center",
                va="center",
                color="white",
                fontsize=8
            )

# 8) Add text label on top of each bar showing total_score
for i, (idx, row) in enumerate(df_plot.iterrows()):
    total_score = row["total_score"]
    ax.text(
        i,
        total_score + 0.1,  # Slight offset above the bar
        f"Score={int(total_score)}",
        ha="center",
        va="bottom",
        color="black",
        fontsize=9,
        fontweight="bold"
    )

# 9) Create secondary axis for stock price
ax2 = ax.twinx()
ax2.set_ylabel("Stock Price (USD)")

# 10) Plot the stock price as a line
ax2.plot(
    df_plot.index, df_plot["stock_price"], 
    color="red", marker="o", markersize=5, linewidth=1.5, 
    label="Stock Price"
)

# 11) Label each stock price point next to the dot
for i, (idx, row) in enumerate(df_plot.iterrows()):
    price = row["stock_price"]
    if price is not None:
        ax2.text(
            i,
            price,
            f"{price}",
            color="red",
            ha="left",
            va="bottom",
            fontsize=8
        )

ax2.legend(loc="lower left", bbox_to_anchor=(1.10, 0))

plt.tight_layout()
plt.show()

4. Key Observations

4.1 Specific Application of the Piotroski Score

The original intention of the Piotroski Score was to help investors focus on value stocks — companies with low price-to-book ratios that exhibit signs of strong fundamentals.

We focused on Microsoft in this article, a large-cap tech stock, however, analysts should ideally apply it to a universe of low price-to-book stocks to determine which are likely to outperform in the future.

4.2 Expanding the Piotroski Framework

While the nine dimensions of the F-Score provide a strong foundation, there is room for refinement:

  • Weighting Factors: Treating each dimension as equally important may not reflect reality. For instance, profitability metrics might carry more weight in high-growth sectors, while liquidity measures may be more relevant for cyclical industries. Assigning weights, derived from data or machine learning models, could enhance precision.

  • Adding Context: Incorporating additional metrics like Altman’s Z-Score (for bankruptcy risk), DuPont analysis (for ROE decomposition), or economic value added could complement Piotroski’s framework, particularly for evaluating modern capital structures.

  • Machine Learning Models: Beyond the static rules of Piotroski, machine learning could dynamically identify which dimensions are most predictive of future returns in specific market environments. For example, high debt ratios may penalize companies during rate hikes but matter less when borrowing costs are low.

I’ll explore some of these elements in future research articles across multiple stocks.

Concluding thoughts

Looking forward, the goal will be to combine fundamental signals with other advanced techniques. I hope to sharpen screening methodologies and better identify undervalued or outperformance-prone equities.

logo

Subscribe to our premium content to read the rest.

Become a paying subscriber to get access to this post and other subscriber-only content.

Upgrade

Keep Reading