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.
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/squad-builder/SKILL.md +1 -1
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/squad-builder/references/rules.md +1 -1
- {fplkit-1.2.0 → fplkit-1.2.1}/.gitignore +1 -2
- {fplkit-1.2.0 → fplkit-1.2.1}/CHANGELOG.md +16 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/CLAUDE.md +1 -1
- {fplkit-1.2.0 → fplkit-1.2.1}/PKG-INFO +2 -2
- {fplkit-1.2.0 → fplkit-1.2.1}/README.md +1 -1
- {fplkit-1.2.0 → fplkit-1.2.1}/docs/command-reference.md +7 -5
- {fplkit-1.2.0 → fplkit-1.2.1}/docs/custom-analysis.md +13 -7
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/_version.py +2 -2
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/transfer_eval.py +4 -1
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/allocate.py +3 -5
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/stats.py +39 -1
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/player_scoring.py +180 -20
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_allocate.py +33 -6
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_stats.py +72 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_common_scoring.py +5 -4
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_data_prep_harmonisation.py +5 -5
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_player_scoring.py +488 -33
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_squad_allocator.py +76 -2
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/README.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/TOOLS.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/SKILL.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/references/output-template.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/references/rules.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/squad-builder/references/output-template.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.agents/skills/update-gw-prep/SKILL.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.claude/output-styles/fpl-mate.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.claude/settings.json +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.env.example +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.github/workflows/ci.yml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/.github/workflows/release.yml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/AGENTS.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/LICENSE +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/cliff.toml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/config/fixture_predictions.yaml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/config/team_managers.yaml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/config/team_ratings.yaml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/config/team_ratings_overrides.yaml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/docs/architecture.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/docs/fpl-rules.md +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/docs/images/fpl-player-demo-v1-0.png +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/action/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/action/waiver.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/bench_order.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/captain.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/starting_xi.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/analysis/stats.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/base.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/common.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/fixture.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/price.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/data/scout.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/orchestration/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/agents/orchestration/report.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/core_insights.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/dataset_fetcher.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/football_data.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/fpl.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/fpl_draft.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/historical.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/historical_types.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/_models.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/anthropic.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/openai_compat.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/providers/perplexity.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/understat.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/api/vaastav.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_banner.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_context.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_fines.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_fines_config.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_helpers.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_json.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_league_recap_data.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_league_recap_types.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_plan_grid.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_analysis.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_classic.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_draft.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/_review_summarisation.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/captain.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/chips.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/credentials.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/differentials.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/fdr.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/fixtures.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/history.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/init.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/league.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/league_recap.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/player.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/preview.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/price_changes.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/price_history.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/ratings.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/review.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/sell_prices.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/squad.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/status.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/targets.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/transfer_eval.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/waivers.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/cli/xg.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/config/defaults.yaml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/constants.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/chip_plan.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/fixture.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/player.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/team.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/models/types.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/parsers/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/parsers/recommendations.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/paths.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/league_recap.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/review.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/prompts/scout.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/scraper/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/scraper/fpl_prices.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/season.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/fixture_predictions.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/matchup.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/player_prior.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/squad_allocator.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/team_form.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/team_ratings.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/services/team_ratings_prior.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/templates/gw_preview.md.j2 +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/templates/gw_review.md.j2 +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/utils/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/fpl_cli/utils/text.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/pyproject.toml +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/pyrightconfig.json +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/requirements.lock +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/__init__.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/conftest.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_action.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_analysis.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_analysis_squad.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_base.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_data.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_data_price.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_agents_orchestration.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_allocate.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_client.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_core_insights.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_dataset_fetcher.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_draft.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_football_data.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_historical.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_understat.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_api_vaastav.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_bench_order_script.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_banner.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_captain.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_chips.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_credentials.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_custom_analysis.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_differentials.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_fdr.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_fdr_blanks.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_fixtures.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_format.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_format_suppression.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_history.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_init.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_json.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_plan_grid.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_player.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_preview.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_price_history.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_review.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_sell_prices.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_squad.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_status.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_targets.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_transfer_eval.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_waivers.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_warnings_stderr.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_cli_xg.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_common.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fines.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fines_config.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fixture_predictions.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_fpl_prices.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_init.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_init_fines.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_league_recap.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_matchup_service.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_models.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_paths.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_player_prior.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_player_resolution.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_providers.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_recommendations_parser.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_report_agent.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_review.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_rolling_pts_per_m.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_scraper.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_season.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_sell_prices.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_starting_xi_agent.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_stats.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_team_ratings.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_team_ratings_prior.py +0 -0
- {fplkit-1.2.0 → fplkit-1.2.1}/tests/test_transfer_eval_agent.py +0 -0
- {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
|
|
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
|
|
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).
|
|
@@ -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.
|
|
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
|
[](https://www.python.org/downloads/)
|
|
39
39
|
[](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
|
[](https://www.python.org/downloads/)
|
|
7
7
|
[](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).
|
|
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
|
|
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
|
|
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
|
|
127
|
-
| GK | 0.7 | Lowest per-
|
|
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
|
-
|
|
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
|
|
338
|
-
- **GK**: dedicated signals via `for_gk()` weights — saves per 90, defensive quality (inverted xGC/90, range 0-2), and clean sheet rate.
|
|
339
|
-
- **DEF**: `dc_per_90` (defensive contribution rate) replaces attacking xG stats via `without_xgi()`.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 2,
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
#
|
|
193
|
-
|
|
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(),
|
|
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(
|
|
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(),
|
|
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 =
|
|
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 =
|
|
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 =
|
|
2266
|
+
waiver_ceiling = _waiver_ceiling_for(evaluation.position)
|
|
2107
2267
|
return normalise_score(score, waiver_ceiling)
|
|
2108
2268
|
|
|
2109
2269
|
|