Buy and sell stock once

1. Problem statement

Given a list of prices for a stock over the past N days (one price per day), determine the optimum days to buy and sell the stock once to maximize profit (Aziz et al., 2018, p. 51).

1.1. Inputs and outputs

  • Input: list of stock prices (positive numbers)
  • Output: day to buy the stock, day to sell the stock, and the realized profit margin

2. Insights

Simply taking the minimum and maximum values of the entire range, and comparing these two numbers, won't work. Example: [10, 5]. Here the only possible buy and sale is to buy at price 10 and sell on the next day at price 5. But doing so would result in a loss. If the price is decreasing each day, then every possible buy/sell combination will result in a loss. In this situation the algorithm should not recommend a buy/sell transaction at all (do nothing).

Here's another example: [10, 9, 8, 1, 2, 6]. Here the maximum possible profit is to buy at 1 and sell at 6, for a profit margin of 5. The minimum and maximum prices are 1 and 10, which are interesting but don't give us the answer.

3. Solutions

3.1. Brute force

Compare every number in the list with every other number. That is, compute every possible combinationa of buy/sell dates, and just return the best (max profit) dates found.

def brute_force(prices: list[int]) -> Optional[tuple[int, int, int]]:
    if not prices:
        return None

    max_profit_so_far = 0
    for buy_date, price_buy in enumerate(prices):
        for sell_date in range(buy_date + 1, len(prices)):
            profit = prices[sell_date] - price_buy
            if profit > max_profit_so_far:
                max_profit_so_far = profit
                transaction_dates = (buy_date, sell_date, profit)

    if max_profit_so_far:
        return transaction_dates

    # If no profitable trade found, return None.
    return None

The time complexity is \(O(n^2)\) where \(n\) is the length of the list of prices.

3.2. Optimal

For the optimal solution, the key is to pretend to sell on that day, using the minimum price found in the previous days as the buying point. The trick here is that the previous minimum price calculation can be updated on each iteration with just a single min() comparison.

def optimal(prices: list[int]) -> Optional[tuple[int, int, int]]:
    if not prices:
        return None

    min_price_so_far = float('inf')
    max_profit = 0

    for date, price in enumerate(prices):
        if price < min_price_so_far:
            buy_date = date
            min_price_so_far = min(price, min_price_so_far)

        max_profit_if_sell_now = price - min_price_so_far
        max_profit_prev = max_profit
        max_profit = max(int(max_profit_if_sell_now), max_profit)

        if max_profit > max_profit_prev:
            transaction_dates = (buy_date, date, max_profit)

    if max_profit:
        return transaction_dates

    # If no profitable trade found, return None.
    return None

The time complexity is \(O(n)\).

3.2.1. As maximum subarray

As Cormen et al. (2009, p. 69) point out, if we look at stock prices not as the prices themselves but as changes in price on each day, then the problem of finding the best buy/sell dates is the same as finding the maximum subarray.

The only annoying thing though, is that the indices are off by 1 (either the buy or sell date) because we lose 1 element to construct the changes array (the transformation of data from the list of prices to the list of changes is lossy). Still, the maximum achievable profit is accurate, and agrees with our optimal solution.

def via_max_subarray(prices: list[int]) -> Optional[tuple[int, int, int]]:
    if not prices:
        return None

    changes = [b - a for (a, b) in zip(prices, prices[1:])]
    max_subarray = dp(changes)
    if max_subarray is None:
        return None

    return (max_subarray.end, max_subarray.end, max_subarray.sum)

4. Tests

🔗
from hypothesis import given, strategies as st
import unittest

from typing import Optional

from maximum_subarray.maximum_subarray import dp

brute_force

optimal

via_max_subarray

class Test(unittest.TestCase):
    cases = [
        ([],                      None),
        ([0],                     None),
        ([0, 0, 0, 0],            None),
        ([3, 2, 1],               None),
        ([5, 25, 100, 50],        (0, 2, 95)),
        ([5, 25, 100, 1, 50, 99], (3, 5, 98)),
    ]

    def test_simple_cases(self):
        for given_prices, expected in self.cases:
            self.assertEqual(brute_force(given_prices), expected)
            self.assertEqual(optimal(given_prices), expected)

            # Check via_max_subarray() (partial) solution. We can't check the
            # exact buy/sell dates because of off-by-1 errors.
            result = via_max_subarray(given_prices)
            if expected is not None and result is not None:
                self.assertEqual(result[2], expected[2])
            else:
                self.assertEqual(result, expected)

    @given(st.lists(st.integers(min_value=1, max_value=100), min_size=0,
                    max_size=14))
    def test_random(self, given_prices: list[int]):
        got = brute_force(given_prices)

        # If the prices were not always decreasing, then there must have been
        # some optimum buy/sell date.
        if given_prices and max(given_prices) > given_prices[0]:
            self.assertNotEqual(got, None)

        # Check that the optimal solution agrees with brute force.
        self.assertEqual(optimal(given_prices), got)

        # Check via_max_subarray() (partial) solution, like we did for
        # test_simple_cases().
        result = via_max_subarray(given_prices)
        if got is not None and result is not None:
            self.assertEqual(result[2], got[2])
        else:
            self.assertEqual(result, got)

if __name__ == "__main__":
    unittest.main(exit=False)

5. References

Aziz, A., Lee, T.-H., & Prakash, A. (2018). Elements of Programming Interviews in Python: The Insiders’ Guide. CreateSpace Independent Publishing Platform (25 July. 2018).
Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed). MIT Press.