Created
October 21, 2025 11:45
-
-
Save SIRHAMY/0983f5b46c5505df8864e739f4d0bd76 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| open FsHttp | |
| open System | |
| open System.Collections | |
| open System.Collections.Generic | |
| open System.Linq | |
| open System.Text.Json | |
| // For more information see https://aka.ms/fsharp-console-apps | |
| printfn "Hello from F#" | |
| let marketDataToken = "TODO_YOUR_TOKEN_HERE" | |
| (* | |
| Strategy: | |
| * Companies I see have great reps in TECH | |
| * Good products | |
| * Good business strategy | |
| * Work long-term | |
| *) | |
| let stockTickers = | |
| [ | |
| // "AAPL" // Free one for MarketData | |
| // "TEAM" // Atlassian | |
| // "NET" // CloudFlare | |
| // "DDOG" // DataDog | |
| // "DOCN" // Digital Ocean | |
| // "GOOG" // Google | |
| // "MSFT" // Microsoft | |
| // "VOO" // Vanguard SP500 | |
| "JOBY" | |
| "WBD" | |
| ] | |
| let expirationYearsToConsider = | |
| [ | |
| 2024 | |
| 2025 | |
| ] | |
| let createDate | |
| (year: int) | |
| (month: int) | |
| (day: int) | |
| = | |
| new DateTimeOffset(DateTime(year, month, day), TimeSpan()) | |
| let findFirstFriday | |
| (year: int) | |
| (month: int) | |
| : DateTimeOffset | |
| = | |
| let firstFridayDay = | |
| seq {1 .. 10} // By def, friday will be in first 10 days | |
| |> Seq.find ( | |
| fun dayIndex -> | |
| let date = createDate year month dayIndex | |
| date.DayOfWeek = DayOfWeek.Friday | |
| ) | |
| createDate year month firstFridayDay | |
| let findAllOptionExpirationDaysInYear | |
| (year: int) | |
| : DateTimeOffset seq | |
| = | |
| (* | |
| Options expiration is third friday of month | |
| *) | |
| let allThirdFridays = | |
| seq { 1 .. 12 } | |
| |> Seq.map ( | |
| fun month -> findFirstFriday year month | |
| ) | |
| |> Seq.map ( | |
| fun firstFriday -> firstFriday.AddDays(14) | |
| ) | |
| allThirdFridays | |
| let expirationDates = | |
| let nowish = DateTimeOffset.UtcNow.AddDays(5) | |
| expirationYearsToConsider | |
| |> List.map ( | |
| fun expirationYear -> findAllOptionExpirationDaysInYear expirationYear | |
| ) | |
| |> List.collect ( | |
| fun expirationDates -> | |
| expirationDates | |
| |> Seq.toList | |
| ) | |
| |> List.filter ( | |
| fun expirationDate -> | |
| expirationDate > nowish | |
| ) | |
| printfn "All ExpirationDates: %A" expirationDates | |
| type MarketDataOptionChainApiResult = | |
| { | |
| optionSymbol : string List | |
| underlying : string List | |
| expiration : int List | |
| strike: float List | |
| dte: int List | |
| bid: float List | |
| bidSize: float List | |
| underlyingPrice: float List // asset price | |
| updated: int List // Last updated time in unix seconds | |
| volume: int List | |
| } | |
| type MarketDataOptionChainResult = | |
| { | |
| optionSymbol : string | |
| underlying : string | |
| expiration : int | |
| strike: float | |
| dte: int | |
| bid: float | |
| bidSize: float | |
| underlyingPrice: float // asset price | |
| Volume: int | |
| LastUpdated: DateTimeOffset | |
| } | |
| type StockPick<'a> = | |
| { | |
| ReturnOnRiskPerYearPercent : float | |
| AbsoluteReturn : float | |
| EffectivePrice: float | |
| EffectiveDiscount: float | |
| Expiration : DateTimeOffset | |
| Bid : float | |
| Strike : float | |
| Data : 'a | |
| } | |
| let createMarketDataOptionChainUrl | |
| (stockSymbol : string) | |
| (expirationDate: DateTimeOffset) | |
| = | |
| let usableDate = expirationDate.ToString("yyyy-MM-dd") | |
| $"https://api.marketdata.app/v1/options/chain/{stockSymbol}/?side=put&range=otm&minVolume=1&expiration={usableDate}" | |
| let fetchMarketDataOptionChainApi | |
| (stockSymbol : string) | |
| (expirationDate: DateTimeOffset) | |
| : MarketDataOptionChainApiResult option | |
| = | |
| let serializerOptions: JsonSerializerOptions = | |
| (JsonSerializerOptions()) | |
| serializerOptions.PropertyNameCaseInsensitive <- true | |
| http { | |
| GET (createMarketDataOptionChainUrl stockSymbol expirationDate) | |
| AuthorizationBearer marketDataToken | |
| } | |
| |> Request.send | |
| |> Response.toJson | |
| |> fun json -> | |
| try | |
| let deserializedJson = | |
| JsonSerializer.Deserialize<MarketDataOptionChainApiResult>(json, serializerOptions) | |
| // ham: Necessary cause sometimes we get bad returns w unexpected null values | |
| match deserializedJson.bid with | |
| | null -> | |
| printfn $"No data available for stock:{stockSymbol} expiration:{expirationDate}" | |
| None | |
| | _ -> Some deserializedJson | |
| with ex -> | |
| printfn $"An error occurred fetching for stock:{stockSymbol} expiration:{expirationDate}" | |
| None | |
| let convertMarketDataOptionChainApiResultToResult | |
| (apiResult: MarketDataOptionChainApiResult option) | |
| : MarketDataOptionChainResult seq | |
| = | |
| if apiResult.IsNone | |
| then Seq.empty | |
| else | |
| let apiResultValue = apiResult.Value | |
| let length = apiResultValue.bid.Count | |
| // printfn "apiResultValue: %A" apiResultValue | |
| // printfn "length: %A" length | |
| Enumerable.Range(0, length - 1) | |
| |> Seq.map ( | |
| fun i -> | |
| { | |
| optionSymbol = apiResultValue.optionSymbol.[i] | |
| underlying = apiResultValue.underlying.[i] | |
| expiration = apiResultValue.expiration.[i] | |
| strike = apiResultValue.strike.[i] | |
| dte = apiResultValue.dte.[i] | |
| bid = apiResultValue.bid.[i] | |
| bidSize = apiResultValue.bidSize.[i] | |
| underlyingPrice = apiResultValue.underlyingPrice.[i] | |
| Volume = apiResultValue.volume.[i] | |
| LastUpdated = DateTimeOffset.FromUnixTimeSeconds(apiResultValue.updated.[i]) | |
| } | |
| ) | |
| let calculateOptionsRewards | |
| (optionsChains: MarketDataOptionChainResult seq) | |
| : StockPick<MarketDataOptionChainResult> seq | |
| = | |
| let calculateRiskOverRewardYearly | |
| (bid: float) | |
| (stockPrice: float) | |
| (daysToExpire: int) | |
| = | |
| let expiryMonths = ((float)daysToExpire) / 30.0 | |
| let monthsToYear = 12.0 / expiryMonths | |
| let percentEarned = (bid / stockPrice) | |
| percentEarned * monthsToYear | |
| optionsChains | |
| |> Seq.choose ( | |
| fun chain -> | |
| let riskOverRewardYearly = | |
| calculateRiskOverRewardYearly | |
| chain.bid | |
| chain.strike | |
| chain.dte | |
| let effectivePrice = (chain.strike - chain.bid) | |
| Some ( | |
| { | |
| ReturnOnRiskPerYearPercent = riskOverRewardYearly | |
| AbsoluteReturn = (chain.bid / chain.strike) | |
| EffectiveDiscount = (effectivePrice / chain.underlyingPrice) - 1.0 | |
| EffectivePrice = effectivePrice | |
| Expiration = DateTimeOffset.FromUnixTimeSeconds(chain.expiration) | |
| Bid = chain.bid | |
| Strike = chain.strike | |
| Data = chain | |
| } | |
| ) | |
| ) | |
| printfn "# Update: Fetching Options Chains" | |
| let results = | |
| stockTickers | |
| |> List.map ( | |
| fun stock -> | |
| expirationDates | |
| |> List.map ( | |
| fun y -> fetchMarketDataOptionChainApi stock y | |
| ) | |
| ) | |
| |> List.collect (fun l -> l) | |
| |> List.map ( | |
| fun apiResult -> convertMarketDataOptionChainApiResultToResult apiResult | |
| ) | |
| |> List.toSeq | |
| |> Seq.collect (fun s -> s) | |
| printfn "# Update: Calculating Results" | |
| let allResults = | |
| results | |
| |> fun s -> calculateOptionsRewards s | |
| printfn "## All Results" | |
| allResults | |
| |> Seq.iter (fun r -> printfn "* %A" r) | |
| |> ignore | |
| printfn "# Update: Choosing Results" | |
| allResults | |
| |> Seq.filter ( | |
| fun r -> | |
| (* | |
| Looking for long-term holds: | |
| * Big discount (30%) | |
| * Good return on money (~10%) | |
| *) | |
| ( | |
| // At least 5p return on money | |
| (r.ReturnOnRiskPerYearPercent > 0.05) | |
| // At least 5p return on money to be worth risk (I get half after tax) | |
| && r.AbsoluteReturn > 0.05 | |
| && r.EffectiveDiscount < -0.25) | |
| (* | |
| Strategy: Cash-good returns: | |
| * High return on risk | |
| * Decent absolute return | |
| *) | |
| || ( | |
| // At least 20p return on year to be good deal | |
| (r.ReturnOnRiskPerYearPercent > 0.25) | |
| // At least 5p return on money to be worth risk (I get half after tax) | |
| && r.AbsoluteReturn > 0.05) | |
| ) | |
| |> Seq.toList | |
| |> List.sortByDescending (fun s -> s.ReturnOnRiskPerYearPercent) | |
| |> Seq.iter ( | |
| fun r -> printfn "GoodPick: %A" r | |
| ) | |
| |> ignore | |
| let now = DateTimeOffset.UtcNow | |
| let oldestData = | |
| allResults | |
| |> Seq.map (fun r -> r.Data.LastUpdated) | |
| |> Seq.min | |
| printfn "OldestData: %A" oldestData |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment