10 min read

Speculating on Animal Crossing Turnip Market

My wife is big fan of Animal Crossing. I will admit that I mostly don’t understand the game but I find it very cute. She was very upset a couple of days ago when the Bank of Nook decided to cut the interest rate down to 0.05%, at which point she asked: “how am I supposed to make money now!?”

Evidently, I said: well, you should invest your money! Apparently, the Financial Times agrees with me.

It seems there’s some sort of turnip market in the game. Basically, you can buy turnips on Sunday for a given price, and then you will have opportunities to sell your turnips over the upcoming week. If you don’t sell them by next Sunday, you lose them (and thus materialize your loss). You get two screen prices every day, one in the AM and one in the PM. So, you’ll get 12 prices from Monday to Saturday.

Can we come up with a good strategy to sell our turnips?

Modelling

The first question is: are turnip prices completely random? A couple of Google searches reveal that they follow four patterns:

  • Big spike: decreasing trend and then three big price spikes before decreasing once again

  • Small spike: decreasing trend and then three small price spikes before decreasing again.

  • Decreasing: always decreasing trend.

  • Random: randomly selected within a range.

Ok, so it seems it’s not completely random. In that case, we should be able to come up with a model (probabilities) for the turnip prices. It turns out that Ninji has already looked into the source code and figured out how the patterns are allocated and how the prices come up. Also, TurnipProphet has already taken this and built a procedure to get the probability distribution for the turnip prices.

Great, job done! Buy low, sell high, make some bank! Well not quite.

Coming up with a strategy

Every turnip investor (speculator) has to make 12 decisions: how many turnips to sell at every screen price. Evidently, every turnip investor (speculator) has a different risk appetite: I might be willing to hold on to my turnips for longer looking for a higher payout which might be unlikely given our probability distribution, or maybe I am not so fond of risk and I hedge my bets out as soon as things get tough. That’s all okay.

Turns out this is all part of what we now know as Modern Portfolio Theory (thanks Mr. Markowitz). You have some risky assets, you have a risk appetite, you want to make a well diversified portfolio that minimizes the variance of your returns, and maximizes your expected returns. That sounds like what we want, let’s do that.

We will model our turnips as 12 different assets: turnips we will sell on Monday AM, turnips we will sell on Monday PM, and so on. Thanks to the work that has been done in the previous section, we know what’s the expected return of each of our 12 assets. As time progresses we will materialize some earnings and some losses.

Let’s work with an example. I put down my turnip prices on TurnipProphet. I did this on Wednesday PM having sold zero turnips so far. I got the following probability distribution:

Probability Distribution

I was too lazy to get the code for TurnipProphet to run, so I just copy pasted it and played around with it manually:

from collections import defaultdict
from functools import partial

import numpy as np
import pandas as pd
import scipy.optimize as sco

MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY = range(6)
AM, PM = "AM", "PM"


def convert_range_with_price(p, s):
    a, b = s.split("to")
    a = a.strip()
    b = b.strip()
    return list(range(int(a) - p, int(b) + 1 - p))


turnip_price = 98
convert_range = partial(convert_range_with_price, turnip_price)

data = [
    (
        0.664,
        {
            (WEDNESDAY, PM): convert_range("66 to 66"),
            (THURSDAY, AM): convert_range("61 to 64"),
            (THURSDAY, PM): convert_range("56 to 61"),
            (FRIDAY, AM): convert_range("51 to 58"),
            (FRIDAY, PM): convert_range("46 to 55"),
            (SATURDAY, AM): convert_range("41 to 52"),
            (SATURDAY, PM): convert_range("36 to 49"),
        },
    ),
    (
        0.158,
        {
            (WEDNESDAY, PM): convert_range("66 to 66"),
            (THURSDAY, AM): convert_range("89 to 138"),
            (THURSDAY, PM): convert_range("138 to 196"),
            (FRIDAY, AM): convert_range("196 to 588"),
            (FRIDAY, PM): convert_range("138 to 196"),
            (SATURDAY, AM): convert_range("89 to 138"),
            (SATURDAY, PM): convert_range("40 to 89"),
        },
    ),
    (
        0.158,
        {
            (WEDNESDAY, PM): convert_range("66 to 66"),
            (THURSDAY, AM): convert_range("61 to 64"),
            (THURSDAY, PM): convert_range("89 to 138"),
            (FRIDAY, AM): convert_range("138 to 196"),
            (FRIDAY, PM): convert_range("196 to 588"),
            (SATURDAY, AM): convert_range("138 to 196"),
            (SATURDAY, PM): convert_range("89 to 138"),
        },
    ),
    (
        0.01,
        {
            (WEDNESDAY, PM): convert_range("66 to 66"),
            (THURSDAY, AM): convert_range("89 to 138"),
            (THURSDAY, PM): convert_range("89 to 138"),
            (FRIDAY, AM): convert_range("137 to 195"),
            (FRIDAY, PM): convert_range("137 to 196"),
            (SATURDAY, AM): convert_range("137 to 195"),
            (SATURDAY, PM): convert_range("40 to 89"),
        },
    ),
    (
        0.01,
        {
            (WEDNESDAY, PM): convert_range("66 to 66"),
            (THURSDAY, AM): convert_range("61 to 64"),
            (THURSDAY, PM): convert_range("89 to 138"),
            (FRIDAY, AM): convert_range("89 to 138"),
            (FRIDAY, PM): convert_range("137 to 195"),
            (SATURDAY, AM): convert_range("137 to 196"),
            (SATURDAY, PM): convert_range("137 to 195"),
        },
    ),
]


So, now we have our data variable holding the probability of each possible return (i.e. price we might get minus the price we paid for the turnips). We can easily calculate the expectation of each asset:

def expectation_of(r1):
    exp = 0
    for p, items in data:
        n = float(len(items[r1]))
        exp += (p * sum(items[r1])) / n

    return exp

I just assumed that if TurnipProphet says a price range of 51 to 55 with 5%, 51 would have a 1% chance of happening, 52 would have a 1% chance of happening. That might obviously be wrong.

So, going back to Markowitz, what we are interested in doing is coming up with a vector of weights for each asset (e.g. sell 50% of your turnips on Friday PM) that is within our risk appetite (higher risk appetite means I am willing to take more risk for higher payout). We would like those weights to stick to the following optimization problem (thanks to the Wikipedia):

Optimization problem

If we translate that to our little problem we need the following ingredients:

  • The covariance matrix of our asset returns.
  • We need to select our risk appetite.

Ok that seems simple enough, we already know how to compute the expected returns, let’s build the covariance matrix:

def joint_prob(r1, r2):
    prob = defaultdict(float)
    for p, items in data:
        r1_opts = items[r1]
        r2_opts = items[r2]
        outcomes = float(len(r1_opts) * len(r2_opts))

        for x in r1_opts:
            for y in r2_opts:
                prob[x, y] += p / outcomes

    return prob


def covariance_of(r1, r2):
    E_r1 = expectation_of(r1)
    E_r2 = expectation_of(r2)

    joint = joint_prob(r1, r2)

    joint_exp = 0
    for (x, y), prob in joint.items():
        joint_exp += x * y * prob

    return joint_exp - E_r1 * E_r2

We are going to calculate the joint probability distribution of the screen prices at two different times (e.g. what’s the probability that the price is X on Monday PM and Y on Friday PM). Using that we use the well known covariance identity Cov(X, Y) = E[XY] - E[X]E[Y]. Presto.

Let’s get all our turnips in a row now. We want to come up with that vector of weights. We have an optimization problem here, we want to select the weights that minimize the formula above. Turns out, we can use scipy just for that.

def objective(weights, covs, expected_returns, risk):
    # the `@` operator is matrix multiplication
    return weights @ covs @ weights - risk * expected_returns @ weights


def get_weights(data, possibilities, risk):
    num_assets = len(possibilities)
    expected_returns_dict = {}
    for p in poss:
        expected_returns_dict[p] = expectation_of(p)

    er = pd.DataFrame({"returns": expected_returns_dict})
    cov_dict = defaultdict(dict)

    for x in poss:
        for y in poss:
            cov_dict[x][y] = covariance_of(x, y)

    covs = pd.DataFrame(cov_dict)

    # We want the % of turnips to sell at each screen price and we can only
    # sell (i.e. we can't short the prices)
    constraints = {"type": "eq", "fun": lambda x: np.sum(x) - 1}
    bound = (0.0, 1.0)
    bounds = tuple(bound for asset in range(num_assets))
    return (
        sco.minimize(
            objective,
            num_assets * [1.0 / num_assets],
            args=(covs.as_matrix(), er["returns"].as_matrix(), risk),
            method="SLSQP",
            bounds=bounds,
            constraints=constraints,
        ),
        er.as_matrix(),
        covs.as_matrix(),
    )

We can test it out interactively with the example we have been working on:

In [171]: poss = [
     ...:     (WEDNESDAY, PM),
     ...:     (THURSDAY, AM),
     ...:     (THURSDAY, PM),
     ...:     (FRIDAY, AM),
     ...:     (FRIDAY, PM),
     ...:     (SATURDAY, AM),
     ...:     (SATURDAY, PM),
     ...: ]
     ...:

In [172]: w, exp, covs = get_weights(data, poss, 0)

In [173]: w['x']
Out[173]:
array([9.99973926e-01, 2.60740769e-05, 0.00000000e+00, 0.00000000e+00,
       1.71303943e-17, 0.00000000e+00, 0.00000000e+00])

In [174]: exp.T @ w['x']
Out[174]: array([-31.99986786])

In [174]: exp.T @ w['x']
Out[174]: array([-31.99986786])

In [175]: w['x'] @ covs @ w['x'].T
Out[175]: 2.4716720412929855e-07

Unsurprisingly, if we are willing to take zero risk, we are told to to sell now as that price has zero variance. The expected returns of that allocation is losing -32 for each turnip. Let’s try a couple of different risk appetites:


In [186]: for risk in (10, 50, 100, 250, 500):
     ...:     w, exp, covs = get_weights(data, poss, risk)
     ...:     print(w)
     ...:     print(exp.T @ w['x'])
     ...:     print(w['x'] @ covs @ w['x'])
     ...:
     fun: 311.77365532878
     jac: array([320.        , 341.42121124, 346.91123962, 320.00033188,
       320.00043488, 441.86499786, 514.03258133])
 message: 'Optimization terminated successfully.'
    nfev: 68
     nit: 6
    njev: 6
  status: 0
 success: True
       x: array([9.72724596e-01, 1.01876057e-10, 6.39677751e-11, 1.46464235e-02,
       1.26289831e-02, 2.04667615e-10, 3.60237807e-10])
[-30.35473053]
8.226350070338594
     fun: 1394.341498381938
     jac: array([1600.        , 1707.10598755, 1734.55581665, 1600.00001526,
       1600.00001526, 2209.32440186, 2570.16264343])
 message: 'Optimization terminated successfully.'
    nfev: 66
     nit: 11
    njev: 7
  status: 0
 success: True
       x: array([8.63622855e-01, 2.96854243e-08, 1.86344942e-08, 7.32320950e-02,
       6.31448364e-02, 5.98173297e-08, 1.05265552e-07])
[-23.77365766]
205.65861551941734
     fun: 2377.365832760326
     jac: array([3200.        , 3414.21282959, 3469.11383057, 3200.00650024,
       3200.00424194, 4418.65078735, 5140.32611084])
 message: 'Optimization terminated successfully.'
    nfev: 92
     nit: 13
    njev: 9
  status: 0
 success: True
       x: array([7.27245514e-01, 6.52789507e-08, 5.03337743e-08, 1.46464389e-01,
       1.26289752e-01, 8.28435152e-08, 1.47684783e-07])
[-15.54729817]
822.6360159129656
     fun: 2858.536370646399
     jac: array([ 8000.        ,  8535.54345703,  8672.83227539,  8000.13848877,
        8000.1831665 , 11046.69415283, 12850.85089111])
 message: 'Optimization terminated successfully.'
    nfev: 99
     nit: 14
    njev: 10
  status: 0
 success: True
       x: array([3.18107970e-01, 8.21149205e-08, 4.67114744e-08, 3.66162862e-01,
       3.15729345e-01, 0.00000000e+00, 0.00000000e+00])
[9.13215268]
5141.574541129883
     fun: -3103.479478698726
     jac: array([16000.        , 16140.99462891, 14426.74719238,  7961.02111816,
        7962.59606934, 18762.31591797, 24061.80737305])
 message: 'Optimization terminated successfully.'
    nfev: 59
     nit: 10
    njev: 6
  status: 0
 success: True
       x: array([0.00000000e+00, 0.00000000e+00, 2.60805565e-10, 5.45772528e-01,
       4.54213107e-01, 6.23904416e-06, 1.60620122e-05])
[28.33816839]
11065.6047138707

So what are we observing? As our risk appetite increases, our weights shift from “cash out now” to “wait it out till Friday because that’s when the prices could peak”. We also observer that our expected returns steadily increase. However, this comes at a cost: the variance of our portfolio increased considerably, thus we are taking on a lot more risk (and possibility to both gain more and lose more).

Ideally, the way you use this is that every time you have a new screen price, you’ll update your portfolio.

  • On Monday AM price you’ll have a new portfolio. You possibly will sell some at the current price, and have the remaining turnips hanging around.

  • On Monday PM price you’ll recompute the weights. You can’t change what you already sold, that ship has gone. But you can make an improved decision (because you have more information) to update the weights of the remaining 11 decisions. You will, again, decide to sell some and keep some given your risk appetite.

  • Continue until you exhaust your weekly turnips. Or evidently, your best friend tells you they have killer prices on their island.

That’s about it.

There’s an interesting scenario I didn’t get to investigate but I think makes for a cool problem which is very similar. Every island has a different turnip price, so turnips are more valuable in some places than others. Let’s say you have access to many islands and access to their turnip probability distribution. How do you select when and where to sell your turnips?