Optimizing My F1 Fantasy Team with Clojure

I wrote a Clojure program to generate possible F1 fantasy teams for me, and learned some valuable lessons in the process.

Optimizing My F1 Fantasy Team with Clojure
F1 car zooming down the track - generated with Midjourney

Intro

The last couple years, I've been participating in an F1 Fantasy League on GridRival. It's been a great way to add some extra stakes and excitement to watching Formula 1 races.

As I'm creating my team each week, I often wonder if I'm maximizing my budget and building the best team available to me. There are countless combinations of constructors and drivers I could pick for any given race week, and as I thought more about it, writing a program to find these combinations seemed like the perfect way to practice my Clojure skills.

If you love Clojure and Formula 1, then I think you'll enjoy this post. If you only love one, then I hope I explain the other adequately enough for this article to make some sense. If you're not into Clojure or Formula 1, well, this might not be the post for you. 😄

Skip ahead to the finished code here.

Basics of F1 Fantasy in GridRival

For the sake of time, I will try to give only enough background here on Formula 1 and GridRival league rules so that the rest of this post makes sense. If you're familiar with F1 and/or fantasy leagues, feel free to skip this section. Or if you simply don't care, also feel free to skip. I will try to distill the problem I'm trying to solve down into it's most basic form below.

  • Currently in F1, there are 10 constructors (teams) consisting of 2 drivers each.
  • At the beginning of the season, you start with 100 million pounds. Each race week, you need to have a full team of 1 constructor and 5 drivers.
  • The constructors and drivers have varying salary amounts based on their performance.
    • For example, Red Bull (the current top constructor) has a salary of 30M, while Kick Sauber is at the bottom with a salary of 7.5M.
    • On the driver side, the top driver in terms of salary at the time of this post is Sergio Perez at 30.5M (if you follow F1 you might be wondering why Max Verstappen isn't the most expensive driver - more on that later), and the cheapest driver is Logan Sergeant at 5.1M
  • You can "sign" constructors and drivers for 1 - 5 races at a time. However, there is a cool-off period which means you can't sign the same driver or constructor for 1 race after their contract expires.
  • After each race, the drivers and constructors are scored based on performance. The scoring takes many factors into account that I won't expand on here, but know that it is more complex than simply their respective finishing position in the race.
    • Crucially, you can choose one driver on your team with a salary below 18M to be a "talent driver". This means their point total is doubled.
    • The salaries of drivers and constructors are then adjusted based on the awarded points. This explains why Sergio Perez currently has a higher salary than Max Verstappen, even though Verstappen has won the first two races this season.

The Problem

Knowing all of this, I wanted to write a Clojure program to help me choose the best possible team on any given race week.

The constraints are contract length and - as always - money. To keep things super simple, I am choosing to use each constructor and driver's 8 race point average as the metric to optimize. This won't be perfect, since as in the stock market, past performance is no guarantee of future results. However, it should be close enough to at least give me an idea on what my best possible team makeup should be for any given race.

I'm sure there is some fancy linear programming method to solving this problem, but I again chose to keep things simple. (If you know how to solve this with linear programming or other solutions, I would love to hear it!) To put it in one sentence, this is the approach I took in solving the problem:

For any given starting team, generate all possible valid teams of 1 constructor and 5 drivers, and take the 10 top teams based on total projected points.

Let's quickly break that down:

  • For any given starting team
    • As stated above, I can sign the constructor and drivers from anywhere between 1 and 5 races. This means I will have an unpredictable starting team for each race based on which drivers and/or constructor are rolling off of contract. My solution should be able to handle every scenario from a completely empty team, to one where I only have one spot to fill.
    • I chose to represent a team using a map. Here's my team going into this weekend's Australian GP scheduled for March 24, 2024:
(def current-team
  {:bank 91.3M :points 0 :constructor nil :drivers
   [{:name "Daniel Ricciardo" :salary 10.1M :points 113}]})

My current team represented as a map

    • You can see here that I currently don't have a constructor, and my only driver is Daniel Ricciardo.
  • generate all possible valid teams of 1 constructor and 5 drivers
    • For this step, I first needed to create the raw data of the available constructors and drivers.
; Only showing two constructors for brevity
(def constructors
  [{:name "Red Bull" :salary 30M :points 162 :eligible? true}
   {:name "Ferrari" :salary 25.6M :points 137 :eligible? true}
   ; ... more constructors
   ])
   
; Similarly, only showing 4 drivers for brevity
(def drivers
  [{:name "Sergio Perez" :salary 30.5M :points 147 :eligible? true}
   {:name "Max Verstappen" :salary 29.7M :points 165 :eligible? false}
   {:name "Charles Leclerc" :salary 26.7M :points 144 :eligible? true}
   {:name "George Russell" :salary 25.6M :points 144 :eligible? true}
   ; ... more drivers
   ])
    • The :points field is each constructor or drivers 8 race average, and the :eligible? key is how I'm denoting whether or not the entity is available to add to my team. You can see above that Max Verstappen is not available since he was previously on my team, amd rolled off of contract after the previous race.
    • The :salary key will be used to deduct from my team's bank when an entity gets assigned to the team. When I'm done generating all of my possible teams, I can filter out any teams with a negative balance. This is what I mean by a "valid" team.
  • and take the 10 top teams based on total projected points.
    • I want to see the top few number of teams so that I can apply my own logic and pick the best team in the moment. Therefore, I'm using this more as a tool to give me an idea of possible teams I could create, instead of expecting my program to produce one "perfect" team.

With that, let's get into my solution!

The Solution

In the typical Clojure way, I've solved this problem by breaking it down into small functions, and then composing them together to arrive at the solution. If you want to skip to the final code, you can check it out on GitHub here. Otherwise, let's start with the first function!

(defn get-eligible [coll]
  (->> coll (keep #(when (:eligible? %) (select-keys % [:name :salary :points])))
       (sort-by #(:salary %))))

EDIT: Thanks to @craftybones on Clojurians Slack for the refactor using keep here

Remember the :eligble? key on the constructors and drivers above? This function simply filters out any where :elgible? equals false, selects the keys :name, :salary, and :points for a simpler map, and finally sorts by the :salary key. I'm using the thread-last macro (->>) to make the function a bit more readable.

Next, I need a way to know how many drivers any given team needs to become a full team. This could be a number from 0 to 5.

(def full-driver-count 5)
(defn needed-driver-count
  [{:keys [drivers]}]
  (- full-driver-count (count drivers)))

Here, I simply subtract the number of drivers on the passed in team from 5 (remember my team map has a :drivers key with a vector of drivers). That's it.

Now that I can tell how many drivers I need, I'm ready to generate all possible driver combinations. For this, I discovered the fantastic math.combinatorics functions. Before discovering this, I had a very crappy recursion based method of generating driver combinations. Instead, combo/combinations allows me to simply generate a lazy sequence of all possible driver combinations based on how many drivers I need to fill my team.

(defn driver-combinations
  [{:keys [drivers] :as team} eligible-drivers]
  (let [drivers-needed (needed-driver-count team)]
    (if (= 0 drivers-needed)
      (list drivers)
      (combo/combinations eligible-drivers drivers-needed))))

You can see here that I'm handling the scenario where I have a full team of drivers by just returning the current team's drivers wrapped in a list. Otherwise, let combo/combinations work its magic!

Now, I need to execute the same idea with constructors. However, since I can only have one constructor on my team, this is much simpler.

(defn possible-constructors
  [{:keys [constructor] :as team} eligible-constructors]
  (if (nil? constructor)
    eligible-constructors
    (list constructor)))

This function simply returns the provided list of eligible constructors if my team doesn't have one, or the team's constructor wrapped in a list.

Now, the next and final few functions are what I'm calling my "transformation" functions. These functions are designed to either create or do some transformation on a potential team I've generated. The first one is the simplest so let's start there:

(defn underbudget? [{:keys [bank]}]
  (>= bank 0))

underbudget? helps me filter out any teams that have exhausted my budget. This simply takes a team and returns true or false based on if that team's :budget is greater than or equal to zero. Nice!

Next, it's finally time to create a potential team. This function expects a team, and then a tuple of a constructor and a list of drivers to add to the team.

(defn create-team
  [{:keys [constructor drivers] :as team} [constructor-to-apply drivers-to-apply]]
  (assoc team :constructor (if (nil? constructor)
                             constructor-to-apply constructor)
         :drivers (if (= 0 (needed-driver-count team))
                    drivers
                    (concat drivers drivers-to-apply))))

Note, that I am not calculating the effects of these constructor and drivers salaries and points on my team. I am merely assigning them so that I have a full possible team consisting of one constructor and 5 drivers.

Next, I want to select my talent driver and double their points. The algorithm I'm using to choose my talent driver for any team is very simple: take the driver with the highest point average below the 18 million salary cutoff for talent drivers.

(defn choose-talent-driver
  [{:keys [drivers] :as team}]
  (let [[under over] (->> drivers (sort-by :salary)
                          (split-with #(<= (:salary %) talent-driver-max-salary)))
        points-desc (sort-by :points #(compare %2 %1) under)
        talent-driver-first (concat points-desc over)
        talent-driver (first talent-driver-first)
        points-doubled (cons (assoc talent-driver
                                    :points
                                    (* 2 (:points talent-driver))) (rest talent-driver-first))]
    (assoc team :drivers points-doubled)))

I struggled a bit with this function, but eventually landed on this implementation of first splitting the drivers into two lists of drivers under and over the 18 million max salary. Then, I can sort the drivers under the cap by :points and take the first one as my talent-driver. Finally, I double my talent drivers :points and put them at the top of the list.

With the talent driver decided, I'm ready to calculate the new :salary and :points value for my team:

(defn apply-salary-and-points
  [{:keys [constructor drivers] :as team}]
  (let [[driver-salary  driver-points]  (reduce
                                         (fn [[acc-salary acc-points] driver]
                                           [(+ acc-salary (:salary driver)) (+ acc-points (:points driver))]) [0 0]
                                         drivers)
        total-salary (+ driver-salary (:salary constructor))
        total-points (+ driver-points (:points constructor))]
    (assoc team
           :bank (- (:bank team) total-salary)
           :points (+ (:points team) total-points))))

In the let binding, I first reduce over the drivers on my team to generate a tuple containing the total salary and total points for all my drivers. Then, I simply add in the constructor's :salary and :points to come up with total-salary and total-points. Those are then used to calculate the updated values for :bank and :points of my team.

Okay! Finally let's put it all together. My main function uses all of these smaller functions and generates the solution for me:

(defn -main
  [& args]
  (->> (combo/cartesian-product (possible-constructors gridrival-data/current-team (get-eligible gridrival-data/constructors))
                                (driver-combinations gridrival-data/current-team (get-eligible gridrival-data/drivers)))
       (into [] (comp
                 (map (partial create-team gridrival-data/current-team))
                 (map choose-talent-driver)
                 (map apply-salary-and-points)
                 (filter underbudget?)))
       (sort-by :points #(compare %2 %1))
       (take 10)))

Here I am once again using the thread-last macro for readability. I start off by using the combo/cartesian-product function to generate all of the possible combinations of constructors and drivers. (The gridrival-data namespace contains the starting data for my team, constructors, and drivers explained above)

Then, I'm using into with a chain of transducers to apply my transformation functions in the following order:

  1. Use create-team to generate a possible team
    1. Here, I'm creating a partial function by supplying the current-team as the first argument to create-team
  2. map choose-talent-driver doubles the points of the selected talent driver
  3. map apply-salary-and-points updates the :bank and :points keys of my team
  4. Finally, filter underbudget? removes any teams that are overbudget and therefore invalid.

After that, all that's left is to sort the possible teams in descending order by :points, and take the first 10. Voila! I now have a list of the best possible teams to choose from.

My Chosen Team

Here is the top team in terms of points from running my solution:

{:bank 0.5M,
  :points 848,
  :constructor {:name "Haas", :salary 9.1M, :points 110},
  :drivers
  ({:name "Estaban Ocon", :salary 13.7M, :points 232}
   {:name "Daniel Ricciardo", :salary 10.1M, :points 113}
   {:name "Lance Stroll", :salary 11.9M, :points 110}
   {:name "Oscar Piastri", :salary 21.2M, :points 138}
   {:name "Lando Norris", :salary 24.8M, :points 145})}

This is great, except for one thing. If you're following F1 this season, you know that the Alpine team is going through a rough time, and their drivers (Esteban Ocon and Pierre Gasly) have been at or near the bottom of the first two races. This means that their 8 race average in terms of points hasn't quite caught up with their downward trend. Given that, I do not want Estaban Ocon on my team. The easy fix for this is to simply mark him and Gasly as :eligible? false. After doing that and rerunning, these are my new top teams to choose from:

({:bank 0.1M,
  :points 845,
  :constructor {:name "Red Bull", :salary 30M, :points 162},
  :drivers
  ({:name "Daniel Ricciardo", :salary 10.1M, :points 226}
   {:name "Nico Hulkenberg", :salary 9.6M, :points 107}
   {:name "Guanyu Zhou", :salary 9.3M, :points 105}
   {:name "Kevin Magnussen", :salary 7.4M, :points 100}
   {:name "Lando Norris", :salary 24.8M, :points 145})}
 {:bank 1.1M,
  :points 843,
  :constructor {:name "Red Bull", :salary 30M, :points 162},
  :drivers
  ({:name "Daniel Ricciardo", :salary 10.1M, :points 226}
   {:name "Lance Stroll", :salary 11.9M, :points 110}
   {:name "Nico Hulkenberg", :salary 9.6M, :points 107}
   {:name "Kevin Magnussen", :salary 7.4M, :points 100}
   {:name "Oscar Piastri", :salary 21.2M, :points 138})})

Out of these two teams, I have actually chosen the second one, with Lance Stroll as my talent driver instead of Daniel Ricciardo. Let's see how I do!

Conclusion

This was a really rewarding mini project for me. It helped drive home some concepts about solving problems with Clojure lazy sequences. When I initially set out to solve it, I started out with an extremely convoluted recursive function that was attempting to do everything all at once. From reading Programming Clojure and other sources, I learned that even though Clojure has advanced recursive capabilities, you rarely need to reach for those tools. In fact, I would say that if I find myself writing a recursive function in the future, it's a signal that I need to take a step back and reanalyze the problem. The fact is, it's much simpler to solve problems using Clojure's built in Seq library. Even though I couldn't take full advantage of lazy evaluation here because of the need for sorting, I still utilized lazy functions from the math.combinatorics library and others.

Link to repo

I hope you enjoyed this article, and if you saw anything here that I could improve, please let me know! Stay tuned for an update on how my first Clojure-assisted team did in the Australian GP.

Post Race Update

Thanks to early retirements from Verstappen and Hamilton, a crash by Russell, and a post-race penalty to Alonso, my team did very well! Even without the retirements by drivers not on my team, I would have performed decently, but with them, my team scored the most points in my league.