fplkit 1.2.0__tar.gz → 1.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/squad-builder/SKILL.md +1 -1
  2. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/squad-builder/references/rules.md +1 -1
  3. {fplkit-1.2.0 → fplkit-1.2.1}/.gitignore +1 -2
  4. {fplkit-1.2.0 → fplkit-1.2.1}/CHANGELOG.md +16 -0
  5. {fplkit-1.2.0 → fplkit-1.2.1}/CLAUDE.md +1 -1
  6. {fplkit-1.2.0 → fplkit-1.2.1}/PKG-INFO +2 -2
  7. {fplkit-1.2.0 → fplkit-1.2.1}/README.md +1 -1
  8. {fplkit-1.2.0 → fplkit-1.2.1}/docs/command-reference.md +7 -5
  9. {fplkit-1.2.0 → fplkit-1.2.1}/docs/custom-analysis.md +13 -7
  10. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/_version.py +2 -2
  11. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/transfer_eval.py +4 -1
  12. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/allocate.py +3 -5
  13. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/stats.py +39 -1
  14. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/player_scoring.py +180 -20
  15. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_allocate.py +33 -6
  16. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_stats.py +72 -0
  17. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_common_scoring.py +5 -4
  18. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_data_prep_harmonisation.py +5 -5
  19. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_player_scoring.py +488 -33
  20. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_squad_allocator.py +76 -2
  21. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/README.md +0 -0
  22. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/TOOLS.md +0 -0
  23. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/SKILL.md +0 -0
  24. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/references/output-template.md +0 -0
  25. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/references/rules.md +0 -0
  26. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  27. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  28. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  29. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/squad-builder/references/output-template.md +0 -0
  30. {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  31. {fplkit-1.2.0 → fplkit-1.2.1}/.claude/output-styles/fpl-mate.md +0 -0
  32. {fplkit-1.2.0 → fplkit-1.2.1}/.claude/settings.json +0 -0
  33. {fplkit-1.2.0 → fplkit-1.2.1}/.env.example +0 -0
  34. {fplkit-1.2.0 → fplkit-1.2.1}/.github/workflows/ci.yml +0 -0
  35. {fplkit-1.2.0 → fplkit-1.2.1}/.github/workflows/release.yml +0 -0
  36. {fplkit-1.2.0 → fplkit-1.2.1}/AGENTS.md +0 -0
  37. {fplkit-1.2.0 → fplkit-1.2.1}/LICENSE +0 -0
  38. {fplkit-1.2.0 → fplkit-1.2.1}/cliff.toml +0 -0
  39. {fplkit-1.2.0 → fplkit-1.2.1}/config/fixture_predictions.yaml +0 -0
  40. {fplkit-1.2.0 → fplkit-1.2.1}/config/team_managers.yaml +0 -0
  41. {fplkit-1.2.0 → fplkit-1.2.1}/config/team_ratings.yaml +0 -0
  42. {fplkit-1.2.0 → fplkit-1.2.1}/config/team_ratings_overrides.yaml +0 -0
  43. {fplkit-1.2.0 → fplkit-1.2.1}/docs/architecture.md +0 -0
  44. {fplkit-1.2.0 → fplkit-1.2.1}/docs/fpl-rules.md +0 -0
  45. {fplkit-1.2.0 → fplkit-1.2.1}/docs/images/fpl-player-demo-v1-0.png +0 -0
  46. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/__init__.py +0 -0
  47. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/__init__.py +0 -0
  48. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/action/__init__.py +0 -0
  49. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/action/waiver.py +0 -0
  50. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/__init__.py +0 -0
  51. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/bench_order.py +0 -0
  52. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/captain.py +0 -0
  53. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  54. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  55. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/stats.py +0 -0
  56. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/base.py +0 -0
  57. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/common.py +0 -0
  58. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/__init__.py +0 -0
  59. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/fixture.py +0 -0
  60. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/price.py +0 -0
  61. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/scout.py +0 -0
  62. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/orchestration/__init__.py +0 -0
  63. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/orchestration/report.py +0 -0
  64. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/__init__.py +0 -0
  65. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/core_insights.py +0 -0
  66. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/dataset_fetcher.py +0 -0
  67. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/football_data.py +0 -0
  68. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/fpl.py +0 -0
  69. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/fpl_draft.py +0 -0
  70. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/historical.py +0 -0
  71. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/historical_types.py +0 -0
  72. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/__init__.py +0 -0
  73. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/_models.py +0 -0
  74. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/anthropic.py +0 -0
  75. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/openai_compat.py +0 -0
  76. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/perplexity.py +0 -0
  77. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/understat.py +0 -0
  78. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/vaastav.py +0 -0
  79. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/__init__.py +0 -0
  80. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_banner.py +0 -0
  81. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_context.py +0 -0
  82. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_fines.py +0 -0
  83. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_fines_config.py +0 -0
  84. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_helpers.py +0 -0
  85. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_json.py +0 -0
  86. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_league_recap_data.py +0 -0
  87. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_league_recap_types.py +0 -0
  88. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_plan_grid.py +0 -0
  89. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_analysis.py +0 -0
  90. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_classic.py +0 -0
  91. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_draft.py +0 -0
  92. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_summarisation.py +0 -0
  93. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/captain.py +0 -0
  94. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/chips.py +0 -0
  95. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/credentials.py +0 -0
  96. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/differentials.py +0 -0
  97. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/fdr.py +0 -0
  98. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/fixtures.py +0 -0
  99. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/history.py +0 -0
  100. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/init.py +0 -0
  101. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/league.py +0 -0
  102. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/league_recap.py +0 -0
  103. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/player.py +0 -0
  104. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/preview.py +0 -0
  105. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/price_changes.py +0 -0
  106. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/price_history.py +0 -0
  107. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/ratings.py +0 -0
  108. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/review.py +0 -0
  109. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/sell_prices.py +0 -0
  110. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/squad.py +0 -0
  111. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/status.py +0 -0
  112. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/targets.py +0 -0
  113. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/transfer_eval.py +0 -0
  114. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/waivers.py +0 -0
  115. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/xg.py +0 -0
  116. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/config/defaults.yaml +0 -0
  117. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/constants.py +0 -0
  118. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/__init__.py +0 -0
  119. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/chip_plan.py +0 -0
  120. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/fixture.py +0 -0
  121. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/player.py +0 -0
  122. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/team.py +0 -0
  123. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/types.py +0 -0
  124. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/parsers/__init__.py +0 -0
  125. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/parsers/recommendations.py +0 -0
  126. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/paths.py +0 -0
  127. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/__init__.py +0 -0
  128. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/league_recap.py +0 -0
  129. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/review.py +0 -0
  130. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/scout.py +0 -0
  131. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/scraper/__init__.py +0 -0
  132. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/scraper/fpl_prices.py +0 -0
  133. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/season.py +0 -0
  134. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/__init__.py +0 -0
  135. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/fixture_predictions.py +0 -0
  136. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/matchup.py +0 -0
  137. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/player_prior.py +0 -0
  138. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/squad_allocator.py +0 -0
  139. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/team_form.py +0 -0
  140. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/team_ratings.py +0 -0
  141. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/team_ratings_prior.py +0 -0
  142. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
  143. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  144. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/templates/gw_review.md.j2 +0 -0
  145. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/utils/__init__.py +0 -0
  146. {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/utils/text.py +0 -0
  147. {fplkit-1.2.0 → fplkit-1.2.1}/pyproject.toml +0 -0
  148. {fplkit-1.2.0 → fplkit-1.2.1}/pyrightconfig.json +0 -0
  149. {fplkit-1.2.0 → fplkit-1.2.1}/requirements.lock +0 -0
  150. {fplkit-1.2.0 → fplkit-1.2.1}/tests/__init__.py +0 -0
  151. {fplkit-1.2.0 → fplkit-1.2.1}/tests/conftest.py +0 -0
  152. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_action.py +0 -0
  153. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_analysis.py +0 -0
  154. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_analysis_squad.py +0 -0
  155. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_base.py +0 -0
  156. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_data.py +0 -0
  157. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_data_price.py +0 -0
  158. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_orchestration.py +0 -0
  159. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_allocate.py +0 -0
  160. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_client.py +0 -0
  161. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_core_insights.py +0 -0
  162. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_dataset_fetcher.py +0 -0
  163. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_draft.py +0 -0
  164. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_football_data.py +0 -0
  165. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_historical.py +0 -0
  166. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_understat.py +0 -0
  167. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_vaastav.py +0 -0
  168. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_bench_order_script.py +0 -0
  169. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_banner.py +0 -0
  170. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_captain.py +0 -0
  171. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_chips.py +0 -0
  172. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_credentials.py +0 -0
  173. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_custom_analysis.py +0 -0
  174. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_differentials.py +0 -0
  175. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_fdr.py +0 -0
  176. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_fdr_blanks.py +0 -0
  177. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_fixtures.py +0 -0
  178. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_format.py +0 -0
  179. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_format_suppression.py +0 -0
  180. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_history.py +0 -0
  181. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_init.py +0 -0
  182. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_json.py +0 -0
  183. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_plan_grid.py +0 -0
  184. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_player.py +0 -0
  185. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_preview.py +0 -0
  186. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_price_history.py +0 -0
  187. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_review.py +0 -0
  188. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_sell_prices.py +0 -0
  189. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_squad.py +0 -0
  190. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_status.py +0 -0
  191. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_targets.py +0 -0
  192. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_transfer_eval.py +0 -0
  193. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_waivers.py +0 -0
  194. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_warnings_stderr.py +0 -0
  195. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_xg.py +0 -0
  196. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_common.py +0 -0
  197. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fines.py +0 -0
  198. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fines_config.py +0 -0
  199. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fixture_predictions.py +0 -0
  200. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fpl_prices.py +0 -0
  201. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_init.py +0 -0
  202. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_init_fines.py +0 -0
  203. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_league_recap.py +0 -0
  204. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_matchup_service.py +0 -0
  205. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_models.py +0 -0
  206. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_paths.py +0 -0
  207. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_player_prior.py +0 -0
  208. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_player_resolution.py +0 -0
  209. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_providers.py +0 -0
  210. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_recommendations_parser.py +0 -0
  211. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_report_agent.py +0 -0
  212. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_review.py +0 -0
  213. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_rolling_pts_per_m.py +0 -0
  214. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_scraper.py +0 -0
  215. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_season.py +0 -0
  216. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_sell_prices.py +0 -0
  217. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_starting_xi_agent.py +0 -0
  218. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_stats.py +0 -0
  219. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_team_ratings.py +0 -0
  220. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_team_ratings_prior.py +0 -0
  221. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_transfer_eval_agent.py +0 -0
  222. {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_utils_text.py +0 -0
@@ -244,7 +244,7 @@ Include all of this in the prompt field, populated with position-specific data:
244
244
  Run `fpl player "{name}" -f -H` **in parallel** for all candidates in your list.
245
245
  For season-start modes with no current-season data, also use `fpl history` data passed in context.
246
246
 
247
- 7. **Scoring:** Score each candidate against the mode-specific criteria from rules. Use quality_score and quality_per_m when available (null for players without Understat data - don't penalise).
247
+ 7. **Scoring:** Score each candidate against the mode-specific criteria from rules. Use `quality_score` and `quality_per_m` when available (null for players without Understat data — don't penalise). **Both fields are elite-within-position: never rank or compare candidates across positions by `quality_score` or `quality_per_m`.** A DEF showing 85 and a MID showing 55 is not "the DEF is better" — the ceilings differ by design. Only compare within the position you are currently filling.
248
248
 
249
249
  8. **Return format:** Return a structured ranked list. Per candidate:
250
250
  ```
@@ -32,7 +32,7 @@ After building, note how many of the user's current players (if known) appear in
32
32
 
33
33
  ## Value-for-Money
34
34
  Available in `fpl player --format json` output when Understat data exists for the player:
35
- - **quality_score** (0-100): Player output quality normalised against positional ceiling. Form and PPG weighted heavily - measures "current FPL points production rate."
35
+ - **quality_score** (0-100): Player output quality normalised against a **position-specific** ceiling. Treat as an **elite-within-position** index: a GK or DEF scoring 90 is "top of their position", not "better than an 85-rated MID/FWD". Raw quality is attenuated by a position multiplier (GK 0.7, DEF 0.85) and DEF/GK use dedicated signal sets (`without_xgi()` / `for_gk()`), so cross-position comparisons of `quality_score` are not meaningful. Form and PPG weighted heavily.
36
36
  - **quality_per_m** (quality_score / price per GBPm): Within-position budget efficiency. Higher = more output per pound.
37
37
  - When choosing between similarly-ranked candidates at the same position, prefer higher `quality_per_m` to free budget for other slots.
38
38
  - `quality_per_m` is not meaningful for cross-position comparison (positional ceilings differ).
@@ -87,8 +87,7 @@ todos/
87
87
  .agents/skills/documentation-writer/
88
88
  .agents/skills/release-notes/
89
89
  .agents/skills/release/
90
- .agents/skills/senior-data-scientist/
91
- .agents/skills/data-scientist/
92
90
  .agents/skills/fpl-data-scientist/
91
+
93
92
  # OS
94
93
  .DS_Store
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
+ ## [1.2.0] - 2026-04-09
5
+
6
+ ### Bug Fixes
7
+
8
+ - gate xGI sustainability behind custom_analysis flag
9
+ - attenuate GK signals for low-minutes keepers
10
+
11
+ ### Features
12
+
13
+ - add player reliability metric (#10)
14
+ - add rolling-window xGI sustainability signal (#11)
15
+ - add GK-specific quality scoring path (#12)
16
+ - fixture-adjusted npxG to remove double-counting (#13)
17
+ - add consistency index signals (Phase 1) (#14)
18
+ - wire consistency signals into scoring (Phase 2) (#15)
19
+
4
20
  ## [1.1.0] - 2026-04-07
5
21
 
6
22
  ### Bug Fixes
@@ -21,7 +21,7 @@ API clients in `fpl_cli/api/`: FPLClient (main API, caches `bootstrap-static/`),
21
21
  - `Player`: `element_type` = position, `team` = team_id, `code` = stable cross-season ID (element_code). Prices in £0.1m units (100 = £10.0m)
22
22
  - `Fixture`: `gameweek` (alias "event"), `home_team_id`/`away_team_id`
23
23
 
24
- For a complete inventory of CLI commands, analysis agents, and skills with JSON support and format awareness, see `.agents/TOOLS.md`.
24
+ For a complete inventory of CLI commands, analysis agents, and skills with JSON support and format awareness, see `.agents/TOOLS.md`. Skills live in `.agents/skills/`.
25
25
 
26
26
  ## Conventions
27
27
  ### CLI Patterns
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fplkit
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: CLI tool for Fantasy Premier League analysis - classic and draft formats
5
5
  Project-URL: Homepage, https://github.com/rossgroomio/fpl-cli
6
6
  Project-URL: Repository, https://github.com/rossgroomio/fpl-cli
@@ -38,7 +38,7 @@ Fantasy Premier League analysis from the terminal. Classic and Draft formats. Si
38
38
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
39
39
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
40
40
 
41
- <img src="docs/images/fpl-player-demo-v1-0.png"
41
+ <img src="https://raw.githubusercontent.com/rossgroomio/fpl-cli/main/docs/images/fpl-player-demo-v1-0.png"
42
42
  alt="FPL player scouting table in fpl-cli showing form, xGI, expected goals, availability flags and Fantasy Premier League data"
43
43
  width="600"
44
44
  style="max-width: 100%; height: auto;">
@@ -6,7 +6,7 @@ Fantasy Premier League analysis from the terminal. Classic and Draft formats. Si
6
6
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8
8
 
9
- <img src="docs/images/fpl-player-demo-v1-0.png"
9
+ <img src="https://raw.githubusercontent.com/rossgroomio/fpl-cli/main/docs/images/fpl-player-demo-v1-0.png"
10
10
  alt="FPL player scouting table in fpl-cli showing form, xGI, expected goals, availability flags and Fantasy Premier League data"
11
11
  width="600"
12
12
  style="max-width: 100%; height: auto;">
@@ -75,7 +75,7 @@ Groups players into tiers:
75
75
  - **Popular** (15-30% owned): Emerging picks
76
76
  - **Differential** (<15% owned): Low-ownership value
77
77
 
78
- Target score combines xG metrics, form, PPG, 3-GW matchup quality, and consistency (CV-xGI percentile bonus, phased in GW6-10), normalised to 0-100. Subject to [early-season shrinkage](custom-analysis.md#early-season-confidence-gw1-10). See [Target Score](custom-analysis.md#target-score) for the full formula.
78
+ Target score combines xG metrics, form, PPG, 3-GW matchup quality, and consistency (CV-xGI percentile bonus, phased in GW6-10), attenuated by position multiplier (GK 0.7, DEF 0.85), and normalised to 0-100. Subject to [early-season shrinkage](custom-analysis.md#early-season-confidence-gw1-10). See [Target Score](custom-analysis.md#target-score) for the full formula.
79
79
 
80
80
  ### Transfer Evaluation
81
81
 
@@ -99,7 +99,7 @@ Output columns:
99
99
  - **Form** - FPL form (last 30 days PPG)
100
100
  - **Status** - availability indicator
101
101
  - **Avail** - historical availability rate (recency-weighted starts across previous seasons). Null when no historical data.
102
- - **Quality** - price-independent player quality (0-100). Uses `VALUE_QUALITY_WEIGHTS`. Null when no Understat match.
102
+ - **Quality** - price-independent player quality (0-100), normalised against a position-specific ceiling. Elite-within-position index — cross-position comparisons not meaningful (GK/DEF/MID/FWD use different ceilings by design). See [Quality & Value Scores](custom-analysis.md#quality--value-scores). Null when no Understat match.
103
103
  - **Value** - quality per GBP million (`quality_score / price`). Higher = more output per pound. Null when no Understat match or price is 0. *(classic only)*
104
104
  - **Price** - current price *(classic only)*
105
105
  - **Budget** - affordability gap: `bank + sell_price - in_price` *(classic only, requires scraper cache)*
@@ -121,7 +121,7 @@ fpl differentials -m 200 # Require 200+ minutes played
121
121
  fpl differentials --format json # JSON envelope (metadata: {gameweek})
122
122
  ```
123
123
 
124
- Differential score combines xG metrics, form, ownership bonus, 3-GW matchup quality, and consistency (inverted CV-xGI bonus - volatile players score higher, phased in GW6-10), normalised to 0-100. Subject to [early-season shrinkage](custom-analysis.md#early-season-confidence-gw1-10). See [Differential Score](custom-analysis.md#differential-score) for the full formula.
124
+ Differential score combines xG metrics, form, ownership bonus, 3-GW matchup quality, and consistency (inverted CV-xGI bonus - volatile players score higher, phased in GW6-10), attenuated by position multiplier (GK 0.7, DEF 0.85), and normalised to 0-100. Subject to [early-season shrinkage](custom-analysis.md#early-season-confidence-gw1-10). See [Differential Score](custom-analysis.md#differential-score) for the full formula.
125
125
 
126
126
  ### Waiver Recommendations
127
127
 
@@ -134,7 +134,7 @@ fpl waivers --format json
134
134
 
135
135
  Identifies squad weaknesses by position, ranks available free agents by waiver score, suggests who to drop for each pickup. This covers the waiver wire (unclaimed players) only - trade recommendations between managers are not in scope.
136
136
 
137
- Waiver score combines xGI, form, PPG, 3-GW matchup quality, and consistency (CV-xGI percentile bonus, phased in GW6-10), normalised to 0-100. Uses a stricter minutes factor than target/differential because draft waivers are a season commitment. Subject to [early-season shrinkage](custom-analysis.md#early-season-confidence-gw1-10). See [Waiver Score](custom-analysis.md#waiver-score) for the full formula.
137
+ Waiver score combines xGI, form, PPG, 3-GW matchup quality, and consistency (CV-xGI percentile bonus, phased in GW6-10), attenuated by position multiplier (GK 0.7, DEF 0.85), and normalised to 0-100. Uses a stricter minutes factor than target/differential because draft waivers are a season commitment. Subject to [early-season shrinkage](custom-analysis.md#early-season-confidence-gw1-10). See [Waiver Score](custom-analysis.md#waiver-score) for the full formula.
138
138
 
139
139
  ## Fixture & Strategic Planning
140
140
 
@@ -169,6 +169,8 @@ Scores ~500 eligible players, adjusts for fixture difficulty over the planning h
169
169
 
170
170
  **JSON output fields:** `id`, `web_name`, `team`, `position`, `price`, `quality_score` (0-100), `raw_quality` (float), `role` (starter/bench), `captain_gws`. Metadata includes `formation`, `budget_used`, `budget_remaining`, `captain_schedule`, `solver_status`.
171
171
 
172
+ **`quality_score` semantics:** At `--horizon >= 2` each player's `quality_score` is normalised against a position-specific VALUE-family ceiling (matching `fpl player` / `fpl stats --value` / `fpl transfer-eval`), so elite GKs, DEFs, MIDs and FWDs all land in comparable 0-100 bands *within their own position*. At `--horizon 1` a single cross-position `STARTING_XI_CEILING` is used — a deliberate asymmetry retained until the single-GW lineup ceilings are split per position; at that horizon, DEFs and GKs display noticeably lower than MIDs/FWDs for the same real-world quality. Use `raw_quality` for a position-agnostic ranking in the single-GW context.
173
+
172
174
  ### Fixture Difficulty (FDR)
173
175
 
174
176
  Analyse upcoming fixture runs with difficulty ratings, blank/double GW detection, and optional squad exposure.
@@ -276,7 +278,7 @@ fpl stats --min-minutes 900 -s expected_goals # Top xG (min 900 mins)
276
278
  fpl stats -p FWD -s form --available-only # FWDs by form, excl. unavailable
277
279
  fpl stats --format json -p MID -s expected_goal_involvements # JSON for agents
278
280
  fpl stats --value -p MID # MIDs ranked by value/£m
279
- fpl stats --value --sort quality_score -p FWD # FWDs by absolute quality
281
+ fpl stats --value --sort quality_score -p FWD # FWDs ranked by within-position quality_score
280
282
  fpl stats --value --window 3 -p MID # Rolling pts/£m over last 3 qualifying GWs
281
283
  ```
282
284
 
@@ -117,16 +117,19 @@ FDR is not an additive component in either scoring family.
117
117
 
118
118
  #### Position Multiplier
119
119
 
120
- Applies to **ceiling components only** (matchup + form + xGI), not to home/pen bonuses:
120
+ Applies to the quality baseline in **both** scoring families:
121
+
122
+ 1. **Single-GW family** (captain, bench, lineup): multiplies ceiling components (matchup + form + xGI) inside `calculate_single_gw_core`. Home and penalty bonuses are not attenuated.
123
+ 2. **Multi-GW ownership family** (target, differential, waiver, value, allocate): multiplies the quality baseline inside `calculate_player_quality_score`. Matchup bonus, ownership bonus and position-need bonus are added un-attenuated on top.
121
124
 
122
125
  | Position | Multiplier | Rationale |
123
126
  |----------|-----------|-----------|
124
127
  | FWD | 1.0 | Highest explosive upside per game (49% drop-off from top-1 to top-10 season scores) |
125
128
  | MID | 1.0 | Similar ceiling to FWD via goals + clean sheet points |
126
- | DEF | 0.85 | Consistent accumulators (28% drop-off) but lower single-GW ceiling |
127
- | GK | 0.7 | Lowest per-game ceiling; value comes from steady accumulation |
129
+ | DEF | 0.85 | Consistent accumulators (28% drop-off) but lower per-GW ceiling |
130
+ | GK | 0.7 | Lowest per-GW ceiling; value comes from steady accumulation |
128
131
 
129
- This means a defender needs a meaningfully better matchup to out-rank a forward as captain. This is intentional: defenders accumulate well over a season (top-10 DEF avg 138 pts vs FWD 131) but captaincy is a single-GW decision where explosive upside matters more.
132
+ The multi-GW path was added on 2026-04-10 to stop cheap GKs (Raya, Kelleher, Darlow) dominating `raw_quality` and forcing the allocator into 5-3-2 / GK-captain solutions. See `docs/plans/2026-04-10-001-fix-multi-gw-scoring-position-rebalance-plan.md` for the empirical rationale and the supersession of the dc-per-90-calibration decision.
130
133
 
131
134
  #### Normalisation
132
135
 
@@ -334,9 +337,12 @@ If Core-Insights data is unavailable, the pipeline falls back to raw npxG/90 wit
334
337
 
335
338
  Available via `fpl stats --value` and `fpl player` when Understat data exists.
336
339
 
337
- **quality_score** (0-100): Normalised player output quality using `VALUE_QUALITY_WEIGHTS`. Weights form and PPG heavily to capture current FPL points production rate. Position-specific scoring paths diverge for defensive players:
338
- - **GK**: dedicated signals via `for_gk()` weights — saves per 90, defensive quality (inverted xGC/90, range 0-2), and clean sheet rate. Normalised against `GK_VALUE_CEILING` (28.2).
339
- - **DEF**: `dc_per_90` (defensive contribution rate) replaces attacking xG stats via `without_xgi()`. Normalised against `VALUE_CEILING` (24.3).
340
+ **quality_score** (0-100): Normalised player output quality using `VALUE_QUALITY_WEIGHTS`. Weights form and PPG heavily to capture current FPL points production rate. Position-specific scoring paths diverge for GK/DEF: raw scores are attenuated by `POSITION_SCORE_MULTIPLIER` before normalisation, **and the normalisation ceiling itself is derived empirically from the active signal set per position**, so `quality_score` is a clean **elite-within-position** index. Cross-position comparisons (e.g. "is Haaland more elite than Raya?") are not meaningful — the ceilings differ by design.
341
+ - **GK**: dedicated signals via `for_gk()` weights — saves per 90, defensive quality (inverted xGC/90, range 0-2), and clean sheet rate. Raw quality attenuated by 0.7 and normalised against `GK_VALUE_CEILING` (~18.8), derived from the `for_gk()` cap sum × 0.7. The form cap uses trajectory-only headroom (`× 1.2`) because `xgi_sustainability` is always 1.0 for non-ATK positions.
342
+ - **DEF**: `dc_per_90` (defensive contribution rate) replaces attacking xG stats via `without_xgi()`. Raw quality attenuated by 0.85 and normalised against `DEF_VALUE_CEILING` (~13.1), derived from the `without_xgi()` cap sum × 0.85 — **not** the MID/FWD-anchored `VALUE_CEILING`. Same trajectory-only form headroom as GK. This replaces a prior MID-anchored scaling that produced a mathematical no-op (both numerator and denominator scaled by 0.85) and compressed real DEF pools into a narrow upper band on ownership scores.
343
+ - **MID/FWD**: unchanged — multiplier 1.0, ceiling `VALUE_CEILING` (24.3).
344
+
345
+ The same four families (target, differential, waiver, value) use analogous per-position ceilings: `GK_TARGET_CEILING` / `DEF_TARGET_CEILING`, etc. Ownership-family ceilings (target / differential / waiver) include headroom for the consistency bonus (max 0.75 target/waiver, 0.375 differential) so a top-pool player with high `cv_xgi_percentile` is not silently clamped to 100 and losing the consistency signal's discrimination. A DEF pool spanning scrub→elite shows a spread of ~30 points across the 0-100 range on each family, up from ~5 points pre-fix.
340
346
 
341
347
  **quality_per_m**: `quality_score / price` (per £m). Within-position budget efficiency - higher means more output per pound. Not meaningful for cross-position comparison. Null when price is 0.
342
348
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.2.0'
22
- __version_tuple__ = version_tuple = (1, 2, 0)
21
+ __version__ = version = '1.2.1'
22
+ __version_tuple__ = version_tuple = (1, 2, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -9,6 +9,7 @@ from fpl_cli.api.fpl import FPLClient
9
9
  from fpl_cli.services.player_scoring import (
10
10
  ConsistencySignals,
11
11
  ScoringContext,
12
+ _as_position,
12
13
  _value_weights_and_ceiling,
13
14
  apply_adjusted_npxg,
14
15
  apply_consistency,
@@ -238,7 +239,9 @@ class TransferEvalAgent(Agent):
238
239
  q_dict = evaluation.as_quality_dict()
239
240
  te_weights, te_ceiling = _value_weights_and_ceiling(player.position_name)
240
241
  mins_factor = calculate_mins_factor(player.minutes, player.appearances, next_gw_id)
241
- raw = calculate_player_quality_score(q_dict, te_weights, mins_factor)
242
+ raw = calculate_player_quality_score(
243
+ q_dict, te_weights, mins_factor, position=_as_position(player.position_name),
244
+ )
242
245
  quality_score = normalise_score(raw, te_ceiling)
243
246
  if identity.price > 0:
244
247
  quality_per_m = round(quality_score / identity.price, 1)
@@ -12,7 +12,7 @@ from rich.table import Table
12
12
 
13
13
  from fpl_cli.cli._context import console
14
14
  from fpl_cli.cli._json import emit_json, emit_json_error, json_output_mode, output_format_option
15
- from fpl_cli.services.player_scoring import STARTING_XI_CEILING, VALUE_CEILING, normalise_score
15
+ from fpl_cli.services.player_scoring import normalise_score, pick_display_ceiling
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from fpl_cli.services.squad_allocator import ScoredPlayer, SquadResult
@@ -141,12 +141,11 @@ def allocate_command(
141
141
  console.print(Panel(msg, title="Allocation Failed", border_style="red"))
142
142
  raise SystemExit(1)
143
143
 
144
- ceiling = STARTING_XI_CEILING if horizon == 1 else VALUE_CEILING
145
144
  _emit_result(
146
145
  result, scoring_data, scored_players,
147
146
  budget, horizon, start_gw, is_json,
148
147
  bench_discount=bd, bench_boost_gw=bench_boost_gw,
149
- free_transfers=free_transfers, ceiling=ceiling,
148
+ free_transfers=free_transfers,
150
149
  )
151
150
 
152
151
  asyncio.run(_run())
@@ -164,7 +163,6 @@ def _emit_result(
164
163
  bench_discount: dict[str, float] | None = None,
165
164
  bench_boost_gw: int | None = None,
166
165
  free_transfers: int = 1,
167
- ceiling: float = VALUE_CEILING,
168
166
  ) -> None:
169
167
  """Format and output the solver result."""
170
168
  player_lookup = {sp.player.id: sp for sp in result.selected_players}
@@ -181,7 +179,7 @@ def _emit_result(
181
179
  ):
182
180
  team = team_map.get(sp.player.team_id)
183
181
  team_short = team.short_name if team else "???"
184
- q_score = normalise_score(sp.raw_quality, ceiling)
182
+ q_score = normalise_score(sp.raw_quality, pick_display_ceiling(sp.position, horizon))
185
183
  role = "starter" if sp.player.id in result.starter_ids else "bench"
186
184
  captain_gws = captain_gws_by_player.get(sp.player.id, [])
187
185
 
@@ -101,6 +101,30 @@ def stats_command(
101
101
  sort_field = "quality_per_m"
102
102
  explicit_value_sort = False
103
103
 
104
+ # --value without -p produces a cross-position ranking that is not meaningful:
105
+ # quality_score is an elite-within-position index, so ordering elite DEFs against
106
+ # elite MIDs on quality_per_m actively misleads. Surface the warning in both
107
+ # channels so tables and JSON pipelines get the same signal:
108
+ #
109
+ # - table mode: human-readable prose on stderr
110
+ # - JSON mode: structured entry in metadata.warnings (agent-native — agents
111
+ # parse JSON, not stderr ANSI)
112
+ #
113
+ # The metadata warning is a list of dicts so additional warnings can be added
114
+ # later without breaking existing consumers.
115
+ _cross_position_warning = (
116
+ value
117
+ and position is None
118
+ and sort_field in _VALUE_SORT_FIELDS
119
+ )
120
+ if _cross_position_warning and output_format != "json":
121
+ error_console.print(
122
+ "[yellow]Warning: --value without --position produces a cross-position "
123
+ "ranking. quality_score and quality_per_m are elite-within-position "
124
+ "indices; comparing GK/DEF against MID/FWD is not meaningful. "
125
+ "Re-run with --position GK|DEF|MID|FWD for a reliable ranking.[/yellow]"
126
+ )
127
+
104
128
  fmt = ctx.obj.format if isinstance(ctx.obj, CLIContext) else None
105
129
  show_draft = fmt in (Format.DRAFT, Format.BOTH)
106
130
 
@@ -281,10 +305,24 @@ def stats_command(
281
305
  # Limit
282
306
  filtered = filtered[:limit]
283
307
 
308
+ warnings: list[dict[str, str]] = []
309
+ if _cross_position_warning:
310
+ warnings.append({
311
+ "code": "cross_position_ranking_not_meaningful",
312
+ "message": (
313
+ "quality_score and quality_per_m are elite-within-position "
314
+ "indices. Sorting across all positions mixes incompatible "
315
+ "scales (GK/DEF ceilings differ from MID/FWD). Re-run with "
316
+ "--position GK|DEF|MID|FWD for a reliable ranking, or "
317
+ "consume raw_quality for a position-agnostic proxy."
318
+ ),
319
+ })
320
+
284
321
  metadata = {"gameweek": None, "format": str(fmt) if fmt else None,
285
322
  "custom_analysis": custom_on,
286
323
  "filters": {"position": position, "sort": sort_field,
287
- "limit": limit, "min_minutes": min_minutes}}
324
+ "limit": limit, "min_minutes": min_minutes},
325
+ "warnings": warnings}
288
326
 
289
327
  if not filtered:
290
328
  if output_format == "json":
@@ -12,7 +12,7 @@ import dataclasses
12
12
  import functools
13
13
  from collections.abc import Mapping
14
14
  from math import inf
15
- from typing import TYPE_CHECKING, Any, Literal, overload
15
+ from typing import TYPE_CHECKING, Any, Literal, cast, overload
16
16
 
17
17
  if TYPE_CHECKING:
18
18
  from fpl_cli.api.core_insights import MatchRecord
@@ -90,7 +90,11 @@ class QualityWeights:
90
90
  penalty_xg=zero,
91
91
  gk_saves_per_90=StatWeight(1.5, 6),
92
92
  gk_xgc_quality=StatWeight(3.0, 3.5),
93
- gk_cs_rate=StatWeight(8.0, 4.0),
93
+ # Halved 2026-04-10: was (8.0, 4.0) - a Phase 1 placeholder that
94
+ # put 50%-CS GKs within 0.8pt of the cap, crowding out xGI signals.
95
+ # At mult=4 the theoretical cap is unchanged (min(1.0*4, 4)=4) so
96
+ # ceiling constants stay valid; only sub-cap contributions drop.
97
+ gk_cs_rate=StatWeight(4.0, 4.0),
94
98
  )
95
99
 
96
100
 
@@ -149,6 +153,8 @@ VALUE_QUALITY_WEIGHTS = QualityWeights(
149
153
  )
150
154
 
151
155
  # Position multiplier: adjusts ceiling for per-game scoring potential (captain + bench)
156
+ Position = Literal["GK", "DEF", "MID", "FWD"]
157
+
152
158
  POSITION_SCORE_MULTIPLIER: dict[str, float] = {
153
159
  "FWD": 1.0,
154
160
  "MID": 1.0,
@@ -156,6 +162,18 @@ POSITION_SCORE_MULTIPLIER: dict[str, float] = {
156
162
  "GK": 0.7,
157
163
  }
158
164
 
165
+
166
+ def _as_position(value: str) -> Position:
167
+ """Narrow an enum-derived position string to the Position literal.
168
+
169
+ Raises ValueError on unknown values (e.g. the "???" fallback from
170
+ Player.position_name when the FPL enum is out of sync). Callers
171
+ passing a known PlayerPosition-derived string should never trip this.
172
+ """
173
+ if value not in POSITION_SCORE_MULTIPLIER:
174
+ raise ValueError(f"Unknown position: {value!r}")
175
+ return cast(Position, value)
176
+
159
177
  ATTACKING_POSITIONS: frozenset[str] = frozenset({"MID", "FWD"})
160
178
 
161
179
 
@@ -180,17 +198,107 @@ STARTING_XI_CEILING = 33.2
180
198
  # Practical ceiling ~24.3 (elite MID scores ~20 raw). Validated: Salah-tier -> 87-92/100
181
199
  VALUE_CEILING = 24.3
182
200
 
183
- # GK-specific ceilings — computed from GK signal caps (saves 6, xgc 3.5, cs 4)
184
- # plus position-appropriate form/ppg caps and matchup/ownership contributions.
185
- # Note: 1.38 = form_trajectory_max(1.2) * xgi_sustainability_max(1.15)
186
- # GK_TARGET: saves 6 + xgc 3.5 + cs 4 + form_cap(5)*1.38 + ppg_cap(4) + matchup 6
187
- GK_TARGET_CEILING = 30.4
188
- # GK_DIFFERENTIAL: saves 6 + xgc 3.5 + cs 4 + form_cap(7)*1.38 + ppg_cap(4) + ownership 5 + matchup 6
189
- GK_DIFFERENTIAL_CEILING = 38.2
190
- # GK_WAIVER: saves 6 + xgc 3.5 + cs 4 + form_cap(7)*1.38 + ppg_cap(4.8) + matchup 6 + position_need 5
191
- GK_WAIVER_CEILING = 39.0
192
- # GK_VALUE: saves 6 + xgc 3.5 + cs 4 + form_cap(7)*1.38 + ppg_cap(5) (no matchup for value)
193
- GK_VALUE_CEILING = 28.2
201
+ # Non-quality bonus caps used when deriving ceiling constants. Keep in sync
202
+ # with _matchup_bonus, the ownership bonus in _calculate_quality_based_raw,
203
+ # and the position-need bonus in calculate_waiver_score.
204
+ _MATCHUP_MAX = 6.0 # matchup_avg_3gw max 8.0 * 0.75 * mins_factor 1.0
205
+ _OWNERSHIP_MAX = 5.0 # (semi_differential_threshold 15 - 0) / divisor 3
206
+ _POSITION_NEED_MAX = 5.0 # calculate_waiver_score empty-slot bonus
207
+ # Form multipliers applied inside calculate_player_quality_score. The ATK
208
+ # path gets form_trajectory_max(1.2) * xgi_sustainability_max(1.15) = 1.38;
209
+ # GK/DEF paths get form_trajectory_max only because compute_xgi_sustainability
210
+ # returns 1.0 for non-ATK positions. The ATK form ceiling is hand-rolled into
211
+ # the TARGET/DIFFERENTIAL/VALUE constants above (see the `form N*1.38` terms);
212
+ # _NON_ATK_FORM_MAX is used by _gk_quality_cap and _def_quality_cap below.
213
+ _NON_ATK_FORM_MAX = 1.2
214
+
215
+ # Consistency bonus headroom — added inside _calculate_quality_based_raw.
216
+ # cv_xgi_percentile in [0,1] × magnitude × 0.5 = max bonus. Value family
217
+ # skips _calculate_quality_based_raw entirely and takes no consistency term.
218
+ _CONSISTENCY_MAX_TARGET = 0.75 # (1.0 - 0.5) * CONSISTENCY_CV_TARGET (1.5)
219
+ _CONSISTENCY_MAX_DIFF = 0.375 # (1.0 - 0.5) * CONSISTENCY_CV_DIFF (0.75)
220
+ _CONSISTENCY_MAX_WAIVER = 0.75 # waiver uses CONSISTENCY_CV_TARGET too
221
+
222
+
223
+ def _gk_quality_cap(weights: QualityWeights) -> float:
224
+ """Theoretical max of calculate_player_quality_score on the GK path.
225
+
226
+ Derived from weight caps, pre-attenuation. Matches the signal set
227
+ evaluated inside calculate_player_quality_score when weights.for_gk()
228
+ is used: saves, xgc, cs, form (trajectory only — xgi_sustainability
229
+ is always 1.0 for GK, see compute_xgi_sustainability), ppg.
230
+ """
231
+ gk = weights.for_gk()
232
+ return (
233
+ gk.gk_saves_per_90.cap
234
+ + gk.gk_xgc_quality.cap
235
+ + gk.gk_cs_rate.cap
236
+ + gk.form.cap * _NON_ATK_FORM_MAX
237
+ + gk.ppg.cap
238
+ )
239
+
240
+
241
+ def _def_quality_cap(weights: QualityWeights) -> float:
242
+ """Theoretical max of calculate_player_quality_score on the DEF path.
243
+
244
+ Matches the signal set under weights.without_xgi(): form (trajectory
245
+ only — xgi_sustainability is always 1.0 for DEF), ppg, and
246
+ dc_per_90. xGI family, GK components and penalty_xg are zeroed by
247
+ without_xgi().
248
+ """
249
+ defw = weights.without_xgi()
250
+ return (
251
+ defw.form.cap * _NON_ATK_FORM_MAX
252
+ + defw.ppg.cap
253
+ + defw.dc_per_90.cap
254
+ )
255
+
256
+
257
+ # Position-specific ceilings — derived at import so a weight change
258
+ # auto-propagates. Quality components attenuated by
259
+ # POSITION_SCORE_MULTIPLIER[position]; matchup, ownership and
260
+ # position-need bonuses are added un-attenuated. Drift is guarded
261
+ # empirically by TestCeilingValidationBands (elite-player bounds).
262
+ _GK_MULT = POSITION_SCORE_MULTIPLIER["GK"]
263
+ _DEF_MULT = POSITION_SCORE_MULTIPLIER["DEF"]
264
+ # Ownership-family ceilings include _CONSISTENCY_MAX_* headroom so a top-pool
265
+ # player with high cv_xgi_percentile does not overflow the ceiling and get
266
+ # silently clamped to 100 (losing the consistency signal's discrimination).
267
+ # MID/FWD TARGET_CEILING / DIFFERENTIAL_CEILING / WAIVER_CEILING already bake
268
+ # this in (see the cv_* terms in their derivation comments above); GK and DEF
269
+ # must add it explicitly because their caps are computed programmatically.
270
+ GK_TARGET_CEILING = (
271
+ _gk_quality_cap(TARGET_QUALITY_WEIGHTS) * _GK_MULT + _MATCHUP_MAX + _CONSISTENCY_MAX_TARGET
272
+ )
273
+ GK_DIFFERENTIAL_CEILING = (
274
+ _gk_quality_cap(DIFFERENTIAL_QUALITY_WEIGHTS) * _GK_MULT
275
+ + _OWNERSHIP_MAX + _MATCHUP_MAX + _CONSISTENCY_MAX_DIFF
276
+ )
277
+ GK_WAIVER_CEILING = (
278
+ _gk_quality_cap(WAIVER_QUALITY_WEIGHTS) * _GK_MULT
279
+ + _MATCHUP_MAX + _POSITION_NEED_MAX + _CONSISTENCY_MAX_WAIVER
280
+ )
281
+ # Value family has no matchup or consistency — compute_quality_value skips
282
+ # _calculate_quality_based_raw entirely.
283
+ GK_VALUE_CEILING = _gk_quality_cap(VALUE_QUALITY_WEIGHTS) * _GK_MULT
284
+
285
+ # DEF ceilings: derived from without_xgi() caps, not MID-anchored ceilings.
286
+ # Replaces the former _position_ceiling("DEF", ...) scaling which produced a
287
+ # mathematical no-op on the VALUE family (both numerator and denominator
288
+ # scaled by 0.85) and a MID-anchored ceiling on target/diff/waiver that
289
+ # compressed real DEF pools into a narrow upper band.
290
+ DEF_TARGET_CEILING = (
291
+ _def_quality_cap(TARGET_QUALITY_WEIGHTS) * _DEF_MULT + _MATCHUP_MAX + _CONSISTENCY_MAX_TARGET
292
+ )
293
+ DEF_DIFFERENTIAL_CEILING = (
294
+ _def_quality_cap(DIFFERENTIAL_QUALITY_WEIGHTS) * _DEF_MULT
295
+ + _OWNERSHIP_MAX + _MATCHUP_MAX + _CONSISTENCY_MAX_DIFF
296
+ )
297
+ DEF_WAIVER_CEILING = (
298
+ _def_quality_cap(WAIVER_QUALITY_WEIGHTS) * _DEF_MULT
299
+ + _MATCHUP_MAX + _POSITION_NEED_MAX + _CONSISTENCY_MAX_WAIVER
300
+ )
301
+ DEF_VALUE_CEILING = _def_quality_cap(VALUE_QUALITY_WEIGHTS) * _DEF_MULT
194
302
 
195
303
  # ---------------------------------------------------------------------------
196
304
  # Consistency scoring magnitudes (Phase 2)
@@ -702,6 +810,8 @@ def calculate_player_quality_score(
702
810
  player: Mapping[str, Any],
703
811
  weights: QualityWeights,
704
812
  mins_factor: float = 1.0,
813
+ *,
814
+ position: Position | None = None,
705
815
  ) -> float:
706
816
  """Shared baseline quality score from form, PPG, and xGI/npxG.
707
817
 
@@ -711,7 +821,9 @@ def calculate_player_quality_score(
711
821
  mins_factor scales per-90 attacking components (npxG, xGChain, xGI
712
822
  fallback, penalty_xG) to discount inflated rates from low-minutes
713
823
  players. Form, ppg, dc_per_90, and GK signals (saves, xgc_quality,
714
- cs_rate) are unscaled.
824
+ cs_rate) are unscaled. When *position* is supplied the final score
825
+ is attenuated by POSITION_SCORE_MULTIPLIER[position]. Default None
826
+ is for formula unit tests that pass naked dicts without a position.
715
827
  """
716
828
  per90 = 0.0
717
829
 
@@ -752,6 +864,9 @@ def calculate_player_quality_score(
752
864
  cs = player.get("gk_cs_rate", 0) or 0
753
865
  score += min(cs * weights.gk_cs_rate.multiplier, weights.gk_cs_rate.cap)
754
866
 
867
+ if position is not None:
868
+ score *= POSITION_SCORE_MULTIPLIER[position]
869
+
755
870
  return score
756
871
 
757
872
 
@@ -1472,15 +1587,55 @@ def build_scoring_enrichment(
1472
1587
  return enrichment
1473
1588
 
1474
1589
 
1590
+ def _target_ceiling_for(position: str) -> float:
1591
+ if position == "GK":
1592
+ return GK_TARGET_CEILING
1593
+ if position == "DEF":
1594
+ return DEF_TARGET_CEILING
1595
+ return TARGET_CEILING
1596
+
1597
+
1598
+ def _differential_ceiling_for(position: str) -> float:
1599
+ if position == "GK":
1600
+ return GK_DIFFERENTIAL_CEILING
1601
+ if position == "DEF":
1602
+ return DEF_DIFFERENTIAL_CEILING
1603
+ return DIFFERENTIAL_CEILING
1604
+
1605
+
1606
+ def _waiver_ceiling_for(position: str) -> float:
1607
+ if position == "GK":
1608
+ return GK_WAIVER_CEILING
1609
+ if position == "DEF":
1610
+ return DEF_WAIVER_CEILING
1611
+ return WAIVER_CEILING
1612
+
1613
+
1475
1614
  def _value_weights_and_ceiling(position: str) -> tuple[QualityWeights, float]:
1476
1615
  """Select VALUE_QUALITY_WEIGHTS variant and ceiling for a position."""
1477
1616
  if position == "GK":
1478
1617
  return VALUE_QUALITY_WEIGHTS.for_gk(), GK_VALUE_CEILING
1479
1618
  if position == "DEF":
1480
- return VALUE_QUALITY_WEIGHTS.without_xgi(), VALUE_CEILING
1619
+ return VALUE_QUALITY_WEIGHTS.without_xgi(), DEF_VALUE_CEILING
1481
1620
  return VALUE_QUALITY_WEIGHTS, VALUE_CEILING
1482
1621
 
1483
1622
 
1623
+ def pick_display_ceiling(position: str, horizon: int) -> float:
1624
+ """Position + horizon aware ceiling for `fpl allocate` display normalisation.
1625
+
1626
+ At horizon <= 1 (single-GW lineup context) returns STARTING_XI_CEILING as
1627
+ a cross-position anchor — a deliberate asymmetry retained until the
1628
+ single-GW lineup ceilings are split per position. Use ``raw_quality`` if
1629
+ you need a position-agnostic ranking in that context. horizon >= 2 uses
1630
+ the VALUE family ceilings, matching `fpl player` / `fpl stats --value` /
1631
+ `fpl transfer-eval` for cross-command consistency.
1632
+ """
1633
+ if horizon <= 1:
1634
+ return STARTING_XI_CEILING
1635
+ _, ceiling = _value_weights_and_ceiling(position)
1636
+ return ceiling
1637
+
1638
+
1484
1639
  @overload
1485
1640
  def compute_quality_value(
1486
1641
  player: Any,
@@ -1539,7 +1694,9 @@ def compute_quality_value(
1539
1694
  q_dict = evaluation.as_quality_dict()
1540
1695
  weights, value_ceiling = _value_weights_and_ceiling(player.position_name)
1541
1696
  mins_factor = calculate_mins_factor(player.minutes, player.appearances, next_gw_id)
1542
- raw_score = calculate_player_quality_score(q_dict, weights, mins_factor)
1697
+ raw_score = calculate_player_quality_score(
1698
+ q_dict, weights, mins_factor, position=_as_position(player.position_name),
1699
+ )
1543
1700
  if raw:
1544
1701
  return raw_score
1545
1702
  q_score = normalise_score(raw_score, value_ceiling)
@@ -1955,7 +2112,10 @@ def _calculate_quality_based_raw(
1955
2112
  )
1956
2113
 
1957
2114
  score = calculate_player_quality_score(
1958
- evaluation.as_quality_dict(), effective_weights, mins_factor,
2115
+ evaluation.as_quality_dict(),
2116
+ effective_weights,
2117
+ mins_factor,
2118
+ position=_as_position(evaluation.position),
1959
2119
  )
1960
2120
 
1961
2121
  # Ownership bonus (differential only)
@@ -2018,7 +2178,7 @@ def calculate_target_score(
2018
2178
  next_gw_id: int,
2019
2179
  ) -> int:
2020
2180
  """Calculate a target score (pure performance, no ownership bias)."""
2021
- ceiling = GK_TARGET_CEILING if evaluation.position == "GK" else TARGET_CEILING
2181
+ ceiling = _target_ceiling_for(evaluation.position)
2022
2182
  return _calculate_quality_based_score(
2023
2183
  evaluation,
2024
2184
  weights=TARGET_QUALITY_WEIGHTS,
@@ -2034,7 +2194,7 @@ def calculate_differential_score(
2034
2194
  next_gw_id: int,
2035
2195
  ) -> int:
2036
2196
  """Calculate a differential score for a player."""
2037
- ceiling = GK_DIFFERENTIAL_CEILING if evaluation.position == "GK" else DIFFERENTIAL_CEILING
2197
+ ceiling = _differential_ceiling_for(evaluation.position)
2038
2198
  return _calculate_quality_based_score(
2039
2199
  evaluation,
2040
2200
  weights=DIFFERENTIAL_QUALITY_WEIGHTS,
@@ -2103,7 +2263,7 @@ def calculate_waiver_score(
2103
2263
  elif current_count == 2:
2104
2264
  score -= 2
2105
2265
 
2106
- waiver_ceiling = GK_WAIVER_CEILING if evaluation.position == "GK" else WAIVER_CEILING
2266
+ waiver_ceiling = _waiver_ceiling_for(evaluation.position)
2107
2267
  return normalise_score(score, waiver_ceiling)
2108
2268
 
2109
2269