diff --git a/TechDocs/extension_value_and_tag_calculations.md b/TechDocs/extension_value_and_tag_calculations.md new file mode 100644 index 0000000..7398360 --- /dev/null +++ b/TechDocs/extension_value_and_tag_calculations.md @@ -0,0 +1,99 @@ +Calculating Extension Values +Note: when referring to current Values, reference the Value of the entire contract, not just the remaining years (should now be saved as a separate variable with the player) + +1. Assign all players a position based on where they played the most snaps (above a certain floor), with primary position as the default + a. Speed Rusher DEs and Pass Rush OLBs are grouped together in an “EDGE” category + b. DTs and all other DEs are grouped together + c. ILBs and all other OLBs are grouped together +2. For all players age 25 and younger, treat their Overall as being 4 points higher than it currently is + The following steps should be applied to each position group separately +3. Within each position group, rank players from highest Overall to lowest, using Age as a secondary metric (ranked lowest to highest) +4. For players ranked at the top of their list, set their Value expectation equal to the Max Value of any current contract from that position group, PLUS 10%. If Overall is tied for the 5th place player, include all players with the same Overall in this category + a. Exception: if the highest-Value contract is at least 150% of second-highest, exclude it from the list of current contracts to compare against (Caruso-Bordewyk Rule) + b. The top tier for each position is the following + i. 5: QB/RB/FB/TE/C/FS/SS/K/P + ii. 10: OT/OG/DT/DE/OLB/ILB + +5. For players not included in #4, create a custom set of current values to compare against. Use all players at the current position except: + a. Do not consider players on rookie contracts + b. Do not consider players on UDFA contracts (soon to be irrelevant) + c. Do not consider contracts of players above a certain age + i. QB/K: 34 and older + ii. RB/FB: 27 and older + iii. All other: 29 and older + +The idea here is to prevent declining veterans at the end of long deals from inflating the expectations of younger mid-tier players + +6. For all players in step #5, set their Value expectation equal to the highest current Value among players of equal or lower Overall in the custom set +7. Smoothing: create a second expectation Value for each player using the outputs from step 4-6. This second number is calculated as the average of the Values from the players who are [Overall +1] and [Overall -1] compared to the current player. + +This took some manual fixing from me since there could be numbers missing, especially at the high end of each group + +8. Set a player’s Value expectation as the higher number between steps 4-7 +9. Adjust the Value based on the Age factor, using the table appropriate for that position (see appendix). This adjusted number is the final Value expectation for that player +10. Set AAV expectation as 40% of the Value expectation + +Calculating Tag Values + +1. Using the same position groups from step 1 above, rank all players within each group based on current year pay (NOT contract Value), calculated as current Bonus + current Salary + a. If a player was traded, treat their Bonus as being what it was before they were traded (this also took a lot of manual work to restore Bonus amounts) +2. Calculate tag amounts based on the following averages of current year pay: + a. Franchise: average of top 5 + b. Transition: average of top 10 + c. Playtime: average of 3rd – 20th + d. Basic: average of 3rd – 25th + +Note: the manual tags that teams can apply to any player during extension season (to automatically add one year to their current contract) always use the Franchise amount. The other tag amounts are only used to calculate 5th year option costs for 1st round draft picks. The appropriate tag is determined automatically using the following criteria: + +1. Franchise: multiple Pro Bowl selections +2. Transition: one Pro Bowl selection +3. Playtime: starter-level snaps (765) in at least 2 of his first 3 seasons +4. Basic: none of the above + +  +Appendix: Value Reduction by Age (and Position) + +QB/K +Age Factor +23 15% +24 10% +25 5% +26 0% +27 -5% +28 -10% +29 -15% +30 -20% +31 -25% +32 -30% +33 -45% +34 -60% +35 -75% +36+ -90% + +RB/FB +Age Factor +23 10% +24 5% +25 0% +26 -10% +27 -20% +28 -30% +29 -45% +30 -60% +31 -75% +32+ -90% + +All Other +Age Factor +23 15% +24 10% +25 5% +26 0% +27 -10% +28 -20% +29 -30% +30 -40% +31 -50% +32 -60% +33 -75% +34+ -90% diff --git a/controller/OffseasonController.go b/controller/OffseasonController.go index 1e7fdad..3ac4bc2 100644 --- a/controller/OffseasonController.go +++ b/controller/OffseasonController.go @@ -16,3 +16,23 @@ func UpdateTeamProfileAffinities(w http.ResponseWriter, r *http.Request) { managers.UpdateTeamProfileAffinities() json.NewEncoder(w).Encode("Done!") } + +func CalculateExtensionValues(w http.ResponseWriter, r *http.Request) { + managers.CalculatePlayerMinimumAndAAVValues() + json.NewEncoder(w).Encode("Done!") +} + +func CalculateTagValues(w http.ResponseWriter, r *http.Request) { + managers.CalculateTagValues() + json.NewEncoder(w).Encode("Done!") +} + +func ResetNFLMinimumValues(w http.ResponseWriter, r *http.Request) { + managers.ResetNFLPlayerMinimumValues() + json.NewEncoder(w).Encode("Done!") +} + +func SeedTagDataFromJSON(w http.ResponseWriter, r *http.Request) { + managers.SeedNFLTagDataFromJSON() + json.NewEncoder(w).Encode("Done!") +} diff --git a/dbprovider/dbprovider.go b/dbprovider/dbprovider.go index 152de9a..1bf0e84 100644 --- a/dbprovider/dbprovider.go +++ b/dbprovider/dbprovider.go @@ -9,6 +9,7 @@ import ( "time" config "github.com/CalebRose/SimFBA/secrets" + "github.com/CalebRose/SimFBA/structs" _ "github.com/jinzhu/gorm/dialects/mysql" "golang.org/x/crypto/ssh" "gorm.io/driver/mysql" @@ -150,6 +151,7 @@ func (p *Provider) InitDatabase() bool { // db.AutoMigrate(&structs.NFLWaiverOffer{}) // db.AutoMigrate(&structs.NFLUDFABoard{}) // db.AutoMigrate(&structs.NFLUDFAProfile{}) + db.AutoMigrate(&structs.NFLTagData{}) // All // db.AutoMigrate(&structs.AdminRecruitModifier{}) diff --git a/main.go b/main.go index 298c030..e5da852 100644 --- a/main.go +++ b/main.go @@ -275,6 +275,10 @@ func handleRequests() http.Handler { // Offseason apiRouter.HandleFunc("/offseason/fix/postseason/status", controller.FixPostseasonStatus).Methods("GET") apiRouter.HandleFunc("/offseason/update/team/profile/affinities", controller.UpdateTeamProfileAffinities).Methods("GET") + apiRouter.HandleFunc("/offseason/nfl/calculate/extension/values", controller.CalculateExtensionValues).Methods("GET") + apiRouter.HandleFunc("/offseason/nfl/calculate/tag/values", controller.CalculateTagValues).Methods("GET") + apiRouter.HandleFunc("/offseason/nfl/reset/minimum/values", controller.ResetNFLMinimumValues).Methods("GET") + apiRouter.HandleFunc("/offseason/nfl/seed/tag/data", controller.SeedTagDataFromJSON).Methods("GET") // Player Controls apiRouter.HandleFunc("/players/all/", controller.AllPlayers).Methods("GET") diff --git a/managers/FreeAgencyManager.go b/managers/FreeAgencyManager.go index 3d0213a..069009f 100644 --- a/managers/FreeAgencyManager.go +++ b/managers/FreeAgencyManager.go @@ -504,7 +504,10 @@ func SignFreeAgent(offer structs.FreeAgencyOffer, FreeAgent structs.NFLPlayer, t db.Save(&FreeAgent) // News Log - message := messageStart + FreeAgent.Position + " " + FreeAgent.FirstName + " " + FreeAgent.LastName + " has signed with the " + NFLTeam.TeamName + " with a contract worth approximately $" + strconv.Itoa(int(Contract.ContractValue)) + " Million Dollars." + totalValue := offer.TotalSalary + offer.TotalBonus + contractValue := offer.ContractValue + years := offer.ContractLength + message := messageStart + FreeAgent.Position + " " + FreeAgent.FirstName + " " + FreeAgent.LastName + " has signed with the " + NFLTeam.TeamName + ". Total: " + util.FormatDollarAmount(totalValue) + " CV: " + util.FormatDollarAmount(contractValue) + " Length: " + strconv.Itoa(years) + " years." CreateNewsLog("NFL", message, "Free Agency", int(offer.TeamID), ts) } @@ -819,16 +822,22 @@ func SyncExtensionOffers() { // Rejects offer e.DeclineOffer(ts.NFLWeek) player.DeclineOffer(ts.NFLWeek) + totalValue := e.TotalSalary + e.TotalBonus + contractValue := e.ContractValue + years := e.ContractLength if e.IsRejected || player.Rejections > 2 { - message = player.Position + " " + player.FirstName + " " + player.LastName + " has rejected an extension offer from " + e.Team + " worth approximately $" + strconv.Itoa(int(e.ContractValue)) + " Million Dollars and will enter Free Agency." + message = player.Position + " " + player.FirstName + " " + player.LastName + " has rejected an extension offer from " + e.Team + ". Total: " + util.FormatDollarAmount(totalValue) + " CV: " + util.FormatDollarAmount(contractValue) + " Length: " + strconv.Itoa(years) + " years, and is no longer negotiating." } else { - message = player.Position + " " + player.FirstName + " " + player.LastName + " has declined an extension offer from " + e.Team + " with an extension worth approximately $" + strconv.Itoa(int(e.ContractValue)) + " Million Dollars, and is still negotiating." + message = player.Position + " " + player.FirstName + " " + player.LastName + " has declined an extension offer from " + e.Team + ". Total: " + util.FormatDollarAmount(totalValue) + " CV: " + util.FormatDollarAmount(contractValue) + " Length: " + strconv.Itoa(years) + " years, and is still negotiating." } CreateNewsLog("NFL", message, "Free Agency", int(e.TeamID), ts) db.Save(&player) } else { e.AcceptOffer() - message = player.Position + " " + player.FirstName + " " + player.LastName + " has accepted an extension offer from " + e.Team + " worth approximately $" + strconv.Itoa(int(e.ContractValue)) + " Million Dollars." + totalValue := e.TotalSalary + e.TotalBonus + contractValue := e.ContractValue + years := e.ContractLength + message = player.Position + " " + player.FirstName + " " + player.LastName + " has accepted an extension offer from " + e.Team + ". Total: " + util.FormatDollarAmount(totalValue) + " CV: " + util.FormatDollarAmount(contractValue) + " Length: " + strconv.Itoa(years) + " years." CreateNewsLog("NFL", message, "Free Agency", int(e.TeamID), ts) db.Save(&team) } @@ -1014,10 +1023,21 @@ func TagPlayer(tagDTO structs.NFLTagDTO) { repository.SaveNFLTeam(nflTeam, db) } - // Get the json file containing all tag data by position and tag type - tagDataBlob := util.GetTagObject() + // Resolve player's position group and look up tag amounts from DB (falls back to JSON) + playerGroup := getPositionGroup(nflPlayerRecord.Position, nflPlayerRecord.Archetype, nflPlayerRecord.Position) + tagAmounts := GetTagAmountsForGroup(playerGroup) fifthYearSalary := 0.5 - fifthYearBonus := tagDataBlob[tagDTO.Position][tagTypeStr] + var fifthYearBonus float64 + switch tagTypeStr { + case "Franchise": + fifthYearBonus = tagAmounts.Franchise + case "Transition": + fifthYearBonus = tagAmounts.Transition + case "Playtime": + fifthYearBonus = tagAmounts.Playtime + default: + fifthYearBonus = tagAmounts.Basic + } extensionOffer := structs.NFLExtensionOffer{ NFLPlayerID: nflPlayerRecord.ID, diff --git a/managers/ProgressionManager.go b/managers/ProgressionManager.go index 25351ad..0eae36c 100644 --- a/managers/ProgressionManager.go +++ b/managers/ProgressionManager.go @@ -467,7 +467,7 @@ func ProgressNFLPlayer(np structs.NFLPlayer, SeasonID string, totalSnaps, SnapsP totalSnaps -= int(snaps.STSnaps) posThreshold := float64(totalSnaps) * 0.8 - if mostPlayedPosition != np.Position && float64(mostPlayedSnaps) > posThreshold { + if mostPlayedPosition != np.Position && float64(mostPlayedSnaps) > posThreshold && np.PositionTwo == "" { // Designate New Position newArchetype, archCheck := getNewArchetype(np.Position, np.Archetype, mostPlayedPosition) // If Archhetype exists, assign new position. Otherwise, progress by old position @@ -476,6 +476,11 @@ func ProgressNFLPlayer(np structs.NFLPlayer, SeasonID string, totalSnaps, SnapsP } else { mostPlayedPosition = np.Position } + if mostPlayedPosition == "RB" && np.Position == "QB" { + // Lower the prime age of the player to that of a RB. + newPrimeAge := util.GetPrimeAge(mostPlayedPosition, newArchetype) + np.AssignPrimeAge(uint(newPrimeAge)) + } } else { mostPlayedPosition = np.Position } @@ -1109,7 +1114,7 @@ func ProgressCollegePlayer(cp structs.CollegePlayer, SeasonID string, stats []st totalSnaps -= int(snaps.STSnaps) posThreshold := float64(totalSnaps) * 0.8 - if mostPlayedPosition != cp.Position && float64(mostPlayedSnaps) > posThreshold { + if mostPlayedPosition != cp.Position && float64(mostPlayedSnaps) > posThreshold && cp.PositionTwo == "" { // Designate New Position newArchetype, archCheck := getNewArchetype(cp.Position, cp.Archetype, mostPlayedPosition) // If Archhetype exists, assign new position. Otherwise, progress by old position @@ -1118,6 +1123,11 @@ func ProgressCollegePlayer(cp structs.CollegePlayer, SeasonID string, stats []st } else { mostPlayedPosition = cp.Position } + if mostPlayedPosition == "RB" && cp.Position == "QB" { + // Lower the prime age of the player to that of a RB. + newPrimeAge := util.GetPrimeAge(mostPlayedPosition, newArchetype) + cp.AssignPrimeAge(uint(newPrimeAge)) + } } else { mostPlayedPosition = cp.Position } @@ -2818,8 +2828,9 @@ func DetermineIfDeclaring(player structs.CollegePlayer, avgSnaps int) bool { // Year 3 AND redshirt == Redshirt Sophomore // Year 2 AND redshirt == Redshirt Freshmen // All players that are freshmen, redshirt freshmen, sophomore, redshirt sophomores, and non-redshirt juniors - isRedshirtJunior := player.Year == 4 && player.IsRedshirt - if !isRedshirtJunior { + isRedshirtSophomore := player.Year == 3 && player.IsRedshirt + isJunior := player.Year == 3 && !player.IsRedshirt + if !isJunior && !isRedshirtSophomore { return false } @@ -2842,11 +2853,11 @@ func DetermineIfDeclaring(player structs.CollegePlayer, avgSnaps int) bool { odds := util.GenerateIntFromRange(1, 100) - snapMod if ovr > 54 && odds <= 25 { return true - } else if ovr > 56 && odds <= 30 { + } else if ovr > 56 && odds <= 35 { return true - } else if ovr > 57 && odds <= 35 { + } else if ovr > 57 && odds <= 45 { return true - } else if ovr > 58 && odds <= 45 { + } else if ovr > 58 && odds <= 55 { return true } else if ovr > 59 && odds <= 70 { return true diff --git a/managers/SchedulerManager.go b/managers/SchedulerManager.go index 5774035..ec1263c 100644 --- a/managers/SchedulerManager.go +++ b/managers/SchedulerManager.go @@ -141,23 +141,25 @@ func ProcessCFBGameRequest(requestID string) { } game := structs.CollegeGame{ - HomeTeamID: int(request.HomeTeamID), - HomeTeam: homeTeam.TeamName, - AwayTeamID: int(request.AwayTeamID), - AwayTeam: awayTeam.TeamName, - Week: int(request.Week), - WeekID: int(request.WeekID), - SeasonID: int(request.SeasonID), - StadiumID: arenaID, - Stadium: stadiumName, - City: city, - State: state, - Region: region, - TimeSlot: timeslot, - IsNeutral: request.IsNeutralSite, - IsConference: false, - IsDivisional: false, - IsSpringGame: request.IsSpringGame, + HomeTeamID: int(request.HomeTeamID), + HomeTeam: homeTeam.TeamAbbr, + HomeTeamCoach: homeTeam.Coach, + AwayTeamCoach: awayTeam.Coach, + AwayTeamID: int(request.AwayTeamID), + AwayTeam: awayTeam.TeamAbbr, + Week: int(request.Week), + WeekID: int(request.WeekID), + SeasonID: int(request.SeasonID), + StadiumID: arenaID, + Stadium: stadiumName, + City: city, + State: state, + Region: region, + TimeSlot: timeslot, + IsNeutral: request.IsNeutralSite, + IsConference: false, + IsDivisional: false, + IsSpringGame: request.IsSpringGame, } repository.CreateCFBGameRecordsBatch(db, []structs.CollegeGame{game}, 1) diff --git a/managers/TransferPortalManager.go b/managers/TransferPortalManager.go index 2bc2d88..eeba747 100644 --- a/managers/TransferPortalManager.go +++ b/managers/TransferPortalManager.go @@ -183,13 +183,13 @@ func ProcessTransferIntention() { } else if p.Year == 2 && !p.IsRedshirt { ageMod = .4 } else if p.Year == 3 && p.IsRedshirt { - ageMod = .7 + ageMod = .75 } else if p.Year == 3 && !p.IsRedshirt { ageMod = 1 } else if p.Year == 4 { ageMod = 1.25 } else if p.Year == 5 { - ageMod = 1.45 + ageMod = 1.5 } /// Higher star players are more likely to transfer @@ -226,7 +226,7 @@ func ProcessTransferIntention() { p.Position == "K" || p.Position == "FB" || p.Position == "C") && idx > 1 { - depthChartCompetitionMod += 33 + depthChartCompetitionMod += 34 } if (p.Position == "RB" || @@ -239,12 +239,12 @@ func ProcessTransferIntention() { p.Position == "OLB" || p.Position == "ILB" || p.Position == "SS") && idx > 2 { - depthChartCompetitionMod += 33 + depthChartCompetitionMod += 34 } if (p.Position == "WR" || p.Position == "CB") && idx > 3 { - depthChartCompetitionMod += 33 + depthChartCompetitionMod += 34 } } } @@ -252,9 +252,9 @@ func ProcessTransferIntention() { // If there's a modifier applied and there's a younger player ahead on the roster, double the amount on the modifier if depthChartCompetitionMod > 0 { if youngerPlayerAhead { - depthChartCompetitionMod += 33 + depthChartCompetitionMod += 34 } else { - depthChartCompetitionMod = .63 * depthChartCompetitionMod + depthChartCompetitionMod = .66 * depthChartCompetitionMod } } @@ -272,8 +272,8 @@ func ProcessTransferIntention() { fcsMod := 1.0 if p.TeamID > 134 && !teamProfile.IsFBS { - if p.Year > 2 && p.Overall > 39 { - fcsMod += (0.1 * float64(p.Year)) + if p.Year > 2 && p.Overall > 38 { + fcsMod += (0.15 * float64(p.Year)) } if p.Personality == "Loyal" { fcsMod = 0.0 @@ -315,9 +315,9 @@ func ProcessTransferIntention() { redshirtSeniorCount++ } - if transferWeight < 30 { + if transferWeight < 34 { lowCount++ - } else if transferWeight < 70 { + } else if transferWeight < 66 { mediumCount++ } else { highCount++ @@ -1124,9 +1124,6 @@ func SyncTransferPortal() { promise := collegePromiseMap[uint(portalProfiles[i].PromiseID.Int64)] teamProfile := teamProfileMap[strconv.Itoa(int(portalProfiles[i].ProfileID))] multiplier := getMultiplier(promise) - if portalProfiles[i].ID == 297478 { - continue - } portalProfiles[i].AddPointsToTotal(multiplier, teamProfile.PortalReputation) } diff --git a/managers/UDFAManager.go b/managers/UDFAManager.go index a3c9adc..7073796 100644 --- a/managers/UDFAManager.go +++ b/managers/UDFAManager.go @@ -175,6 +175,7 @@ func SignUDFA(draftee structs.NFLPlayer, bid structs.NFLUDFAProfile) { Y2BaseSalary: 0.5, Y3BaseSalary: 0.5, IsActive: true, + ContractType: "UDFA", } contract.CalculateContract() // Updates the AAV and total values db.Create(&contract) diff --git a/managers/ValuationManager.go b/managers/ValuationManager.go new file mode 100644 index 0000000..3000471 --- /dev/null +++ b/managers/ValuationManager.go @@ -0,0 +1,680 @@ +package managers + +import ( + "log" + "math" + "sort" + "strconv" + + "github.com/CalebRose/SimFBA/dbprovider" + "github.com/CalebRose/SimFBA/repository" + "github.com/CalebRose/SimFBA/structs" + "github.com/CalebRose/SimFBA/util" +) + +// ============================================================================= +// Constants +// ============================================================================= + +const snapPositionFloor uint16 = 50 + +// starterSnapThreshold is the minimum snaps-per-season to qualify as a starter +// for playtime tag type determination. +const starterSnapThreshold = 765 + +// ============================================================================= +// Internal types +// ============================================================================= + +type playerGroupEntry struct { + Player structs.NFLPlayer + Contract structs.NFLContract +} + +type rankedGroupEntry struct { + Entry playerGroupEntry + AdjOverall int // overall with +4 adjustment for players <= age 25 +} + +// ============================================================================= +// Position-group helpers +// ============================================================================= + +// getPositionGroup returns the calculation group for a player. +// EDGE grouping is always determined by the stored position + archetype; +// snap-based position is used for all other positions. +func getPositionGroup(storedPos, archetype, snapPos string) string { + // EDGE: Speed Rusher DEs and Pass Rush OLBs + if storedPos == "DE" && archetype == "Speed Rusher" { + return "EDGE" + } + if storedPos == "OLB" && archetype == "Pass Rush" { + return "EDGE" + } + + effectivePos := snapPos + if effectivePos == "" { + effectivePos = storedPos + } + + switch effectivePos { + case "DE", "DT": + return "DL" + case "OLB", "ILB": + return "LB" + default: + return effectivePos // QB, RB, FB, WR, TE, OT, OG, C, CB, FS, SS, K, P + } +} + +// determineSnapPosition returns the position where the player played the most +// snaps this season, provided it meets the minimum floor. Falls back to +// storedPos if no position reaches the floor. +func determineSnapPosition(storedPos string, snaps structs.NFLPlayerSeasonSnaps) string { + snapsByPos := map[string]uint16{ + "QB": snaps.QBSnaps, + "RB": snaps.RBSnaps, + "FB": snaps.FBSnaps, + "WR": snaps.WRSnaps, + "TE": snaps.TESnaps, + "OT": snaps.OTSnaps, + "OG": snaps.OGSnaps, + "C": snaps.CSnaps, + "DE": snaps.DESnaps, + "DT": snaps.DTSnaps, + "OLB": snaps.OLBSnaps, + "ILB": snaps.ILBSnaps, + "CB": snaps.CBSnaps, + "FS": snaps.FSSnaps, + "SS": snaps.SSSnaps, + "K": snaps.KSnaps, + "P": snaps.PSnaps, + } + + var bestPos string + var maxSnaps uint16 + for pos, count := range snapsByPos { + if count >= snapPositionFloor && count > maxSnaps { + maxSnaps = count + bestPos = pos + } + } + if bestPos == "" { + return storedPos + } + return bestPos +} + +// getTopTierCount returns the number of players in the "elite" tier for a group. +func getTopTierCount(group string) int { + switch group { + case "OT", "OG", "DL", "EDGE", "LB", "WR", "CB": + return 10 + default: // QB, RB, FB, TE, C, FS, SS, K, P + return 5 + } +} + +// getAgeExclusionThreshold returns the minimum age (inclusive) at which a +// player is excluded from the custom mid-tier comparison set (step 5). +func getAgeExclusionThreshold(group string) int { + switch group { + case "QB", "K": + return 34 + case "RB", "FB": + return 27 + default: + return 29 + } +} + +// getAgeAdjustmentFactor returns the decimal adjustment factor for a player's +// age and position group (e.g., +0.15 means +15%, -0.10 means −10%). +func getAgeAdjustmentFactor(group string, age int) float64 { + switch group { + case "QB", "K": + if age >= 36 { + return -0.90 + } + factors := map[int]float64{ + 23: 0.15, 24: 0.10, 25: 0.05, 26: 0.00, + 27: -0.05, 28: -0.10, 29: -0.15, 30: -0.20, + 31: -0.25, 32: -0.30, 33: -0.45, 34: -0.60, 35: -0.75, + } + if f, ok := factors[age]; ok { + return f + } + return 0.00 + + case "RB", "FB": + if age >= 32 { + return -0.90 + } + factors := map[int]float64{ + 23: 0.10, 24: 0.05, 25: 0.00, 26: -0.10, + 27: -0.20, 28: -0.30, 29: -0.45, 30: -0.60, 31: -0.75, + } + if f, ok := factors[age]; ok { + return f + } + return 0.00 + + default: + if age >= 34 { + return -0.90 + } + factors := map[int]float64{ + 23: 0.15, 24: 0.10, 25: 0.05, 26: 0.00, + 27: -0.10, 28: -0.20, 29: -0.30, 30: -0.40, + 31: -0.50, 32: -0.60, 33: -0.75, + } + if f, ok := factors[age]; ok { + return f + } + return 0.00 + } +} + +func avgFloats(vals []float64) float64 { + if len(vals) == 0 { + return 0 + } + var total float64 + for _, v := range vals { + total += v + } + return total / float64(len(vals)) +} + +// ============================================================================= +// Extension value calculation (per group) +// ============================================================================= + +// computeGroupExtensionValues calculates the expected minimum contract value +// and AAV for every player in a position group, following the steps in the +// TechDocs/extension_value_and_tag_calculations.md document. +func computeGroupExtensionValues(group string, entries []playerGroupEntry) []structs.NFLPlayer { + if len(entries) == 0 { + return nil + } + + topTierCount := getTopTierCount(group) + ageThreshold := getAgeExclusionThreshold(group) + + // Step 2: compute adjusted overall (age <= 25 gets +4 bonus for ranking) + ranked := make([]rankedGroupEntry, len(entries)) + for i, e := range entries { + adj := int(e.Player.Overall) + if int(e.Player.Age) <= 25 { + adj += 4 + } + ranked[i] = rankedGroupEntry{Entry: e, AdjOverall: adj} + } + + // Step 3: sort by adjusted overall desc, age asc + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].AdjOverall != ranked[j].AdjOverall { + return ranked[i].AdjOverall > ranked[j].AdjOverall + } + return ranked[i].Entry.Player.Age < ranked[j].Entry.Player.Age + }) + + // Determine the adjusted-overall floor for the top tier. + // All players tied at the Nth place are included (per "tied at 5th" rule). + topTierMinOverall := math.MinInt32 // default: all players are top tier + if topTierCount < len(ranked) { + topTierMinOverall = ranked[topTierCount-1].AdjOverall + } + + // Step 4a: collect active contract signing values for top-tier reference + var groupSigningValues []float64 + for _, e := range entries { + if e.Contract.IsActive { + groupSigningValues = append(groupSigningValues, e.Contract.SigningValue) + } + } + sort.Slice(groupSigningValues, func(i, j int) bool { + return groupSigningValues[i] > groupSigningValues[j] + }) + + var maxContractValue float64 + if len(groupSigningValues) > 0 { + maxContractValue = groupSigningValues[0] + // Caruso-Bordewyk Rule: if the highest value is >= 150% of the second + // highest, exclude it to avoid distorting the top-tier reference. + if len(groupSigningValues) > 1 && maxContractValue >= groupSigningValues[1]*1.5 { + maxContractValue = groupSigningValues[1] + } + } + topTierExpectedValue := maxContractValue * 1.10 + + // First pass: assign raw (pre-smoothing) expected values + rawValues := make([]float64, len(ranked)) + for i, rp := range ranked { + if rp.AdjOverall >= topTierMinOverall { + // Step 4: elite tier — max contract value + 10% + rawValues[i] = topTierExpectedValue + } else { + // Steps 5–6: mid-tier — highest signing value among players with + // equal-or-lower actual overall in the filtered custom set + actualOverall := int(rp.Entry.Player.Overall) + var bestValue float64 + for _, e := range entries { + c := e.Contract + if !c.IsActive { + continue + } + // Exclude rookies, UDFAs, and age-ineligible players + if c.ContractType == "Rookie" || c.ContractType == "UDFA" { + continue + } + if int(e.Player.Age) >= ageThreshold { + continue + } + if int(e.Player.Overall) <= actualOverall && c.SigningValue > bestValue { + bestValue = c.SigningValue + } + } + rawValues[i] = bestValue + } + } + + // Step 7: smoothing — average values of players at (overall+1) and (overall-1) + adjOverallValMap := make(map[int][]float64) + for i, rp := range ranked { + adjOverallValMap[rp.AdjOverall] = append(adjOverallValMap[rp.AdjOverall], rawValues[i]) + } + + result := make([]structs.NFLPlayer, len(ranked)) + for i, rp := range ranked { + rawVal := rawValues[i] + + // Build smoothing components from adjacent overall levels + var smoothComponents []float64 + if higherVals, ok := adjOverallValMap[rp.AdjOverall+1]; ok { + smoothComponents = append(smoothComponents, avgFloats(higherVals)) + } + if lowerVals, ok := adjOverallValMap[rp.AdjOverall-1]; ok { + smoothComponents = append(smoothComponents, avgFloats(lowerVals)) + } + smoothedVal := avgFloats(smoothComponents) + + // Step 8: take the higher of raw and smoothed values + bestVal := rawVal + if smoothedVal > bestVal { + bestVal = smoothedVal + } + + // Step 9: apply age adjustment + ageFactor := getAgeAdjustmentFactor(group, int(rp.Entry.Player.Age)) + adjustedVal := bestVal * (1.0 + ageFactor) + if adjustedVal < 0 { + adjustedVal = 0 + } + + // Step 10: AAV = 40% of value expectation + aav := adjustedVal * 0.40 + + p := rp.Entry.Player + p.AssignCalculatedValues(adjustedVal, aav) + result[i] = p + } + + return result +} + +// ============================================================================= +// CalculatePlayerMinimumAndAAVValues +// ============================================================================= + +// CalculatePlayerMinimumAndAAVValues calculates and assigns the minimum +// contract (extension) value and AAV for every active NFL player. +// Run once per offseason. Also updates OriginalMinimumValue / OriginalAAV so +// the week-15 reset restores to this freshly computed baseline. +func CalculatePlayerMinimumAndAAVValues() { + db := dbprovider.GetInstance().GetDB() + ts := GetTimestamp() + seasonID := strconv.Itoa(ts.NFLSeasonID) + + nflPlayers := GetAllNFLPlayers() + contracts := repository.FindAllActiveNFLContracts() + seasonSnapsMap := GetNFLPlayerSeasonSnapMap(seasonID) + + // Build contract lookup keyed by NFL player ID + contractMap := make(map[int]structs.NFLContract, len(contracts)) + for _, c := range contracts { + contractMap[c.NFLPlayerID] = c + } + + // Group all players by their position group + groupEntries := make(map[string][]playerGroupEntry) + for _, p := range nflPlayers { + snapPos := determineSnapPosition(p.Position, seasonSnapsMap[p.ID]) + group := getPositionGroup(p.Position, p.Archetype, snapPos) + groupEntries[group] = append(groupEntries[group], playerGroupEntry{ + Player: p, + Contract: contractMap[int(p.ID)], + }) + } + + // Calculate extension values per group, then save + saved := 0 + for group, entries := range groupEntries { + updated := computeGroupExtensionValues(group, entries) + for _, p := range updated { + if err := db.Model(&p).Updates(map[string]interface{}{ + "minimum_value": p.MinimumValue, + "original_minimum_value": p.OriginalMinimumValue, + "aav": p.AAV, + "original_aav": p.OriginalAAV, + }).Error; err != nil { + log.Printf("ValuationManager: failed to save player %d (%s): %v", p.ID, group, err) + continue + } + saved++ + } + } + + log.Printf("CalculatePlayerMinimumAndAAVValues: updated %d players", saved) +} + +// ============================================================================= +// CalculateTagValues +// ============================================================================= + +// CalculateTagValues computes tag amounts for every position group based on +// current-year pay (Y1BaseSalary + Y1Bonus) and saves them to the NFLTagData +// table. It also assigns TagType to each player (Franchise / Transition / +// Playtime / Basic) based on Pro Bowl selections and snap history. +func CalculateTagValues() { + db := dbprovider.GetInstance().GetDB() + ts := GetTimestamp() + seasonID := strconv.Itoa(ts.NFLSeasonID) + + nflPlayers := GetAllNFLPlayers() + contracts := repository.FindAllActiveNFLContracts() + allSeasonSnaps := repository.FindAllNFLPlayerSeasonSnaps() + seasonSnapsMap := GetNFLPlayerSeasonSnapMap(seasonID) + + // Contract lookup by player ID + contractMap := make(map[int]structs.NFLContract, len(contracts)) + for _, c := range contracts { + contractMap[c.NFLPlayerID] = c + } + + // Historical snap lookup: playerID -> seasonID -> snaps + histSnapsMap := make(map[uint]map[uint]structs.NFLPlayerSeasonSnaps) + for _, s := range allSeasonSnaps { + if histSnapsMap[s.PlayerID] == nil { + histSnapsMap[s.PlayerID] = make(map[uint]structs.NFLPlayerSeasonSnaps) + } + histSnapsMap[s.PlayerID][s.SeasonID] = s + } + + // Group players (with active contracts) by position group, collecting pay + type payEntry struct { + Player structs.NFLPlayer + Pay float64 // Y1BaseSalary + Y1Bonus + Contract structs.NFLContract + } + groupPayEntries := make(map[string][]payEntry) + + for _, p := range nflPlayers { + c, ok := contractMap[int(p.ID)] + if !ok || !c.IsActive { + continue + } + snapPos := determineSnapPosition(p.Position, seasonSnapsMap[p.ID]) + group := getPositionGroup(p.Position, p.Archetype, snapPos) + groupPayEntries[group] = append(groupPayEntries[group], payEntry{ + Player: p, + Pay: c.Y1BaseSalary + c.Y1Bonus, + Contract: c, + }) + } + + // Compute and persist tag amounts for each group + existingTagData := repository.FindAllNFLTagData() + tagDataByPos := make(map[string]structs.NFLTagData, len(existingTagData)) + for _, td := range existingTagData { + tagDataByPos[td.Position] = td + } + + for group, entries := range groupPayEntries { + // Sort by current-year pay descending + sort.Slice(entries, func(i, j int) bool { + return entries[i].Pay > entries[j].Pay + }) + + pays := make([]float64, len(entries)) + for i, e := range entries { + pays[i] = e.Pay + } + + td := tagDataByPos[group] + td.Position = group + td.Franchise = avgFloats(topN(pays, 5)) + td.Transition = avgFloats(topN(pays, 10)) + td.Playtime = avgFloats(rangeN(pays, 2, 19)) // ranks 3–20 (0-indexed 2–19) + td.Basic = avgFloats(rangeN(pays, 2, 24)) // ranks 3–25 (0-indexed 2–24) + + repository.SaveNFLTagData(td, db) + } + + // Assign TagType to each player based on Pro Bowls and first-3-season snaps + for _, p := range nflPlayers { + newTagType := determineTagType(p, ts.NFLSeasonID, histSnapsMap) + if newTagType == p.TagType { + continue + } + if err := db.Model(&p).Update("tag_type", newTagType).Error; err != nil { + log.Printf("ValuationManager: failed to update tag_type for player %d: %v", p.ID, err) + } + } + + log.Printf("CalculateTagValues: tag amounts computed for %d position groups", len(groupPayEntries)) +} + +// topN returns the first n elements of an already-sorted (descending) slice. +func topN(sorted []float64, n int) []float64 { + if n > len(sorted) { + n = len(sorted) + } + return sorted[:n] +} + +// rangeN returns elements at 0-based indices [start, end] of a sorted slice. +// Both bounds are inclusive. If the slice is shorter than needed, returns what +// is available within the range. +func rangeN(sorted []float64, start, end int) []float64 { + if start >= len(sorted) { + return nil + } + if end >= len(sorted) { + end = len(sorted) - 1 + } + return sorted[start : end+1] +} + +// determineTagType returns the appropriate TagType constant for a player: +// +// 0 == Basic +// 1 == Franchise (multiple Pro Bowls) +// 2 == Transition (one Pro Bowl) +// 3 == Playtime (starter snaps in 2 of first 3 seasons) +func determineTagType(p structs.NFLPlayer, currentSeasonID int, histSnaps map[uint]map[uint]structs.NFLPlayerSeasonSnaps) uint8 { + if p.ProBowls >= 2 { + return 1 // Franchise + } + if p.ProBowls == 1 { + return 2 // Transition + } + + // Playtime: >= 765 snaps in at least 2 of the player's first 3 NFL seasons + if meetsPlaytimeCriteria(p, currentSeasonID, histSnaps) { + return 3 // Playtime + } + + return 0 // Basic +} + +// meetsPlaytimeCriteria returns true if the player had starter-level snaps +// (>= 765) in at least 2 of their first 3 NFL seasons. +func meetsPlaytimeCriteria(p structs.NFLPlayer, currentSeasonID int, histSnaps map[uint]map[uint]structs.NFLPlayerSeasonSnaps) bool { + if p.Experience == 0 { + return false + } + firstSeasonID := uint(currentSeasonID) - p.Experience + + qualifyingSeasons := 0 + for offset := uint(0); offset < 3; offset++ { + sid := firstSeasonID + offset + playerSeasons, ok := histSnaps[p.ID] + if !ok { + continue + } + snaps, ok := playerSeasons[sid] + if !ok { + continue + } + if snaps.GetTotalSnaps() >= starterSnapThreshold { + qualifyingSeasons++ + } + } + return qualifyingSeasons >= 2 +} + +// ============================================================================= +// ResetNFLPlayerMinimumValues +// ============================================================================= + +// ResetNFLPlayerMinimumValues resets every player's working MinimumValue and +// AAV back to their stored originals. Called around week 15 of the regular +// season so that players enter the offseason with fresh, freshly-calculated +// baselines rather than values degraded by DecreaseMinimumValue calls. +func ResetNFLPlayerMinimumValues() { + db := dbprovider.GetInstance().GetDB() + + nflPlayers := GetAllNFLPlayers() + + reset := 0 + for _, p := range nflPlayers { + if p.MinimumValue == p.OriginalMinimumValue && p.AAV == p.OriginalAAV { + continue // already at baseline, skip the write + } + p.ResetMinimumAndAAVValues() + if err := db.Model(&p).Updates(map[string]interface{}{ + "minimum_value": p.MinimumValue, + "aav": p.AAV, + }).Error; err != nil { + log.Printf("ValuationManager: failed to reset player %d: %v", p.ID, err) + continue + } + reset++ + } + + log.Printf("ResetNFLPlayerMinimumValues: reset %d players", reset) +} + +// ============================================================================= +// SeedNFLTagDataFromJSON +// ============================================================================= + +// SeedNFLTagDataFromJSON populates the NFLTagData table from the legacy +// tagData.json file. Call this once after running the AutoMigrate to create +// the table; subsequent updates should go through CalculateTagValues(). +// +// NOTE: the JSON stores data by individual position (DE, OLB, ILB, DT, …). +// For grouped positions (EDGE, DL, LB) the seeding logic takes the arithmetic +// average of the constituent positions as a reasonable starting point. +func SeedNFLTagDataFromJSON() { + db := dbprovider.GetInstance().GetDB() + + raw := util.GetTagData() + + // Positions that map 1-to-1 with groups + directGroups := []string{"QB", "RB", "FB", "WR", "TE", "OT", "OG", "C", "CB", "FS", "SS", "K", "P"} + for _, pos := range directGroups { + vals, ok := raw[pos] + if !ok { + continue + } + td := repository.FindNFLTagDataByPosition(pos) + td.Position = pos + td.Franchise = vals["Franchise"] + td.Transition = vals["Transition"] + td.Playtime = vals["Playtime"] + td.Basic = vals["Basic"] + repository.SaveNFLTagData(td, db) + } + + // EDGE = average of DE and OLB entries from JSON + edge := seedNFLTagGroup(raw, "EDGE", []string{"DE", "OLB"}) + existing := repository.FindNFLTagDataByPosition("EDGE") + edge.Model = existing.Model // preserve existing DB row if present + repository.SaveNFLTagData(edge, db) + + // DL = average of DT and DE entries + dl := seedNFLTagGroup(raw, "DL", []string{"DT", "DE"}) + existingDL := repository.FindNFLTagDataByPosition("DL") + dl.Model = existingDL.Model + repository.SaveNFLTagData(dl, db) + + // LB = average of ILB and OLB entries + lb := seedNFLTagGroup(raw, "LB", []string{"ILB", "OLB"}) + existingLB := repository.FindNFLTagDataByPosition("LB") + lb.Model = existingLB.Model + repository.SaveNFLTagData(lb, db) + + log.Printf("SeedNFLTagDataFromJSON: seeding complete") +} + +// seedNFLTagGroup builds an NFLTagData record for a merged group by averaging +// the constituent positions from the raw JSON map. +func seedNFLTagGroup(raw map[string]map[string]float64, group string, positions []string) structs.NFLTagData { + var fran, trans, play, basic float64 + count := 0 + for _, pos := range positions { + vals, ok := raw[pos] + if !ok { + continue + } + fran += vals["Franchise"] + trans += vals["Transition"] + play += vals["Playtime"] + basic += vals["Basic"] + count++ + } + if count == 0 { + return structs.NFLTagData{Position: group} + } + n := float64(count) + return structs.NFLTagData{ + Position: group, + Franchise: fran / n, + Transition: trans / n, + Playtime: play / n, + Basic: basic / n, + } +} + +// GetTagAmountsForGroup returns the tag dollar amounts for a given position +// group (e.g., "EDGE", "QB"). Prefers DB data; falls back to JSON. +func GetTagAmountsForGroup(group string) structs.NFLTagData { + td := repository.FindNFLTagDataByPosition(group) + if td.ID != 0 { + return td + } + // If not in DB yet, build from JSON + raw := util.GetTagData() + if vals, ok := raw[group]; ok { + return structs.NFLTagData{ + Position: group, + Franchise: vals["Franchise"], + Transition: vals["Transition"], + Playtime: vals["Playtime"], + Basic: vals["Basic"], + } + } + return structs.NFLTagData{Position: group} +} diff --git a/repository/NFLTagRepository.go b/repository/NFLTagRepository.go new file mode 100644 index 0000000..03c6fe6 --- /dev/null +++ b/repository/NFLTagRepository.go @@ -0,0 +1,40 @@ +package repository + +import ( + "log" + + "github.com/CalebRose/SimFBA/dbprovider" + "github.com/CalebRose/SimFBA/structs" + "gorm.io/gorm" +) + +func FindAllNFLTagData() []structs.NFLTagData { + var tagData []structs.NFLTagData + db := dbprovider.GetInstance().GetDB() + if err := db.Find(&tagData).Error; err != nil { + log.Printf("Error loading NFLTagData: %v", err) + } + return tagData +} + +func FindNFLTagDataByPosition(position string) structs.NFLTagData { + var tagData structs.NFLTagData + db := dbprovider.GetInstance().GetDB() + db.Where("position = ?", position).First(&tagData) + return tagData +} + +func SaveNFLTagData(tagData structs.NFLTagData, db *gorm.DB) { + if err := db.Save(&tagData).Error; err != nil { + log.Printf("Error saving NFLTagData for position %s: %v", tagData.Position, err) + } +} + +func FindAllNFLPlayerSeasonSnaps() []structs.NFLPlayerSeasonSnaps { + var snaps []structs.NFLPlayerSeasonSnaps + db := dbprovider.GetInstance().GetDB() + if err := db.Find(&snaps).Error; err != nil { + log.Printf("Error loading NFLPlayerSeasonSnaps: %v", err) + } + return snaps +} diff --git a/structs/NFLPlayer.go b/structs/NFLPlayer.go index c794a75..0802241 100644 --- a/structs/NFLPlayer.go +++ b/structs/NFLPlayer.go @@ -76,6 +76,13 @@ func (np *NFLPlayer) AssignMinimumValue(val, aav float64) { np.AAV = aav } +// AssignCalculatedValues sets both the working values and the originals so that +// ResetMinimumAndAAVValues() will restore them to this newly computed baseline. +func (np *NFLPlayer) AssignCalculatedValues(val, aav float64) { + np.OriginalMinimumValue = val + np.OriginalAAV = aav +} + func (np *NFLPlayer) ShowRealAttributeValue() { np.ShowLetterGrade = false } @@ -239,6 +246,12 @@ func (np *NFLPlayer) Progress(attr CollegePlayerProgressions) { np.PotentialGrade = attr.PotentialGrade } np.Rejections = 0 + np.ResetMinimumAndAAVValues() +} + +func (f *NFLPlayer) ResetMinimumAndAAVValues() { + f.MinimumValue = f.OriginalMinimumValue + f.AAV = f.OriginalAAV } func (f *NFLPlayer) MapSeasonStats(seasonStats NFLPlayerSeasonStats) { diff --git a/structs/TagValues.go b/structs/TagValues.go new file mode 100644 index 0000000..fe866a1 --- /dev/null +++ b/structs/TagValues.go @@ -0,0 +1,15 @@ +package structs + +import "github.com/jinzhu/gorm" + +// NFLTagData stores the calculated tag amounts for a position group. +// One row per position group (e.g., "QB", "EDGE", "DL", "LB", "WR", etc.). +// Updated each offseason by CalculateTagValues. +type NFLTagData struct { + gorm.Model + Position string + Franchise float64 + Transition float64 + Playtime float64 + Basic float64 +} diff --git a/util/HelperUtil.go b/util/HelperUtil.go index 5bf20c7..b97f168 100644 --- a/util/HelperUtil.go +++ b/util/HelperUtil.go @@ -1,9 +1,17 @@ package util +import "fmt" + func GetNFLFullTeamName(teamName, mascot string) string { return teamName + " " + mascot } +// FormatDollarAmount formats a float64 as a dollar string to one decimal place +// (e.g., 12.3 → "$12.3M"). +func FormatDollarAmount(amount float64) string { + return fmt.Sprintf("$%.1fM", amount) +} + func GetWeekID(seasonID uint, week uint) uint { // WeekID structure is the final two digits of the season year followed by the two digits representing the week. // Season 1 == 2021