fplkit 2.7.0__tar.gz → 2.7.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-2.7.0 → fplkit-2.7.1}/.agents/TOOLS.md +1 -1
- {fplkit-2.7.0 → fplkit-2.7.1}/CHANGELOG.md +23 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/PKG-INFO +1 -1
- {fplkit-2.7.0 → fplkit-2.7.1}/docs/architecture.md +4 -4
- {fplkit-2.7.0 → fplkit-2.7.1}/docs/command-reference.md +7 -4
- {fplkit-2.7.0 → fplkit-2.7.1}/docs/fpl-rules.md +10 -4
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/_version.py +2 -2
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/fpl_draft.py +97 -16
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_league_recap_data.py +14 -7
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_league_recap_types.py +21 -18
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_classic.py +6 -1
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_draft.py +5 -3
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_summarisation.py +13 -5
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/league.py +24 -2
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/review.py +46 -9
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/text.py +42 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_draft.py +129 -6
- fplkit-2.7.1/tests/test_cli_league.py +161 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_review.py +67 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_recap.py +95 -5
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_review.py +114 -0
- fplkit-2.7.1/tests/test_utils_text.py +106 -0
- fplkit-2.7.0/tests/test_cli_league.py +0 -67
- fplkit-2.7.0/tests/test_utils_text.py +0 -54
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/README.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/build-test-plan/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/create-pr/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/references/output-template.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/references/rules.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/preview-ingest/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/release/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/release-notes/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/squad-builder/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/squad-builder/references/output-template.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/squad-builder/references/rules.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/update-gw-prep/SKILL.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.claude/output-styles/fpl-mate.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.claude/settings.json +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.env.example +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.github/workflows/ci.yml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.github/workflows/pr-title.yml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.github/workflows/release.yml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/.gitignore +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/AGENTS.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/CLAUDE.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/CONTRIBUTING.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/LICENSE +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/README.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/cliff.toml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/config/team_ratings_overrides.yaml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/docs/custom-analysis.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/docs/images/fpl-player-demo-v1-0.png +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/action/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/action/waiver.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/bench_order.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/captain.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/starting_xi.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/stats.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/base.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/common.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/fixture.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/price.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/scout.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/orchestration/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/orchestration/report.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/contract.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/core_insights.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/dataset_fetcher.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/football_data.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/fpl.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/historical.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/historical_types.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/_http.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/_models.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/anthropic.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/openai_compat.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/perplexity.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/understat.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/vaastav.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_banner.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_context.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_fines_config.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_helpers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_json.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_league_recap_history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_plan_grid.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_analysis.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/allocate.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/captain.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/chips.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/credentials.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/differentials.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/doctor.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/doctor_providers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/fdr.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/fixtures.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/init.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/intel.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/league_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/league_recap.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/player.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/preview.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/price_changes.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/price_history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/ratings.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/returnees.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/review.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/sell_prices.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/squad.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/stats.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/status.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/targets.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/transfer_eval.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/waivers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/xg.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/defaults.yaml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/fixture_predictions.yaml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/team_managers.yaml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/constants.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/chip_plan.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/fixture.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/league_history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/player.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/team.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/types.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/parsers/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/parsers/recommendations.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/paths.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/league_recap.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/returnees.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/scout.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/scraper/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/scraper/fpl_prices.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/season.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/fixture_predictions.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history_counters.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history_notes.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/matchup.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/player_clubs.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/player_prior.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/returnee_radar.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/constants.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/data_prep.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/display.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/evaluation.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/ownership.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/shrinkage.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/signals.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/single_gw.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/value_quality.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/season_previews.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/squad_allocator.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/team_form.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/team_ratings.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/team_ratings_prior.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/templates/gw_preview.md.j2 +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/templates/gw_review.md.j2 +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/files.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/gameweek.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/markdown.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/teams.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/time.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/pyproject.toml +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/pyrightconfig.json +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/scripts/backtest_early_season_prior_blend.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/scripts/calibrate_promoted_prior.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/scripts/calibrate_quality_ceilings.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/__init__.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/conftest.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/classic_squad_fixture.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/recommendations_fixture.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/waiver_ranked_golden.json +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_action.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_analysis.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_analysis_squad.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_base.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_data.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_data_price.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_orchestration.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_allocate.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_client.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_core_insights.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_dataset_fetcher.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_football_data.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_historical.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_understat.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_vaastav.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_bench_order_script.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_calibrate_quality_ceilings.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_classic_squad_extraction.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_allocate.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_banner.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_captain.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_chips.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_context_settings.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_credentials.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_custom_analysis.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_differentials.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_doctor.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_doctor_providers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_failure_parity.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_failure_streams.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fdr.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fdr_blanks.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fixtures.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_format.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_format_suppression.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fpl_block.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_init.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_intel.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_json.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_json_contract.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_league_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_league_recap.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_plan_grid.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_player.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_preview.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_price_changes.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_price_history.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_returnees.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_sell_prices.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_settings_warning.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_squad.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_stats.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_status.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_targets.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_transfer_eval.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_waivers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_warnings_stderr.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_xg.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_common.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_common_scoring.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_data_prep_harmonisation.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_dependencies.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_doc_consistency.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fines_config.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fixture_predictions.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fpl_prices.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_gw_prep_script_bootstrap.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_init.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_init_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_counters.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_fines.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_notes.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_store.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_matchup_service.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_models.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_normalise_entities.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_output_style_sync.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_paths.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_player_clubs.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_player_prior.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_player_resolution.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_preseason.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_prompt_tables.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_providers.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_recommendations_parser.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_report_agent.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_report_paths.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_returnee_radar.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_rolling_pts_per_m.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_scoring.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_scraper.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_season.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_season_awareness.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_season_previews.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_sell_prices.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_skill_fdr_quality_signals.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_skill_stats_thresholds.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_squad_allocator.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_starting_xi_agent.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_starting_xi_script.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_stats.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_team_ratings.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_team_ratings_prior.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_transfer_eval_agent.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_transfer_eval_script.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_utils_gameweek.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_utils_markdown.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_utils_time.py +0 -0
- {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_validate_draft_waivers.py +0 -0
|
@@ -39,7 +39,7 @@ with `2>/dev/null` without losing the table, whichever command it runs.
|
|
|
39
39
|
| `player` | Look up a player's stats, xG, ownership and fixture run. Before GW10 `quality_score` blends last season's pedigree in and JSON `metadata.warnings` says so (`early_season_prior_informed`, or `early_season_small_sample` when history could not be loaded), same as `fpl stats --value`; `understat_team_unmatched` joins it for any club Understat carried no players for, whose whole squad loses xG enrichment. JSON includes `ep_next` (predicted pts next GW) and `ep_this` (current GW), emitting `null` when FPL has no projection; panel shows `xPts` (ep_next) when available, omits the segment when FPL has no projection. `--detail` (`-d`) shows GW-by-GW performance and, for FWD/MID with sufficient history, xGI sustainability (per-match GI-xGI divergence -> form modifier). With `custom_analysis`: JSON adds `info.adjusted_npxg_per_90` (fixture-adjusted) and `info.raw_npxg_per_90` (Understat season avg); panel shows `adj. npxG/90: X.XXX (raw: Y.YYY)` | Yes | General | No | direct-api |
|
|
40
40
|
| `stats` | List players with filtering and sorting. `--value` adds quality/value per £m columns; `--window N` sets rolling lookback (3-10) for `rolling_pts_per_m`. Before GW10, `--value` quality scores blend last season's pts/90 pedigree with this season's observation and the command says so (stderr notice quoting the observation's weight band; JSON `metadata.warnings` code `early_season_prior_informed`); when last season's history cannot be loaded they are pure observation and the notice says that instead (`early_season_small_sample`, before GW6). `understat_team_unmatched` is raised once per club Understat carried no players for, whose whole squad scores without xG. GK ceilings scale with the calendar-possible sample, reaching full scale at GW6. Sortable by `ep_next`/`ep_this` (FPL predicted points; `ep_this` is only meaningful before the gameweek's first kickoff — FPL rolls it forward to `ep_next` as each player's match finishes); both in JSON output (emit `null` when FPL has no projection; table renders `—`). Players with no projection sort to the bottom in either direction | Yes | General | No | direct-api |
|
|
41
41
|
| `history` | Show historical player performance across seasons. A provider that refuses exits 1 in both output formats, carrying the upstream's own reason: the error envelope used to say only `Failed to fetch historical data` while the cause went to stderr, and table mode printed the cause to stdout and exited 0 | Yes | General | No | direct-api |
|
|
42
|
-
| `league` | Show live league standings for Classic and Draft leagues | No | General | No | direct-api |
|
|
42
|
+
| `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break | No | General | No | direct-api |
|
|
43
43
|
| `fdr` | Analyse fixture difficulty - easy runs, blanks, doubles. Three payloads, not one: the default call runs the agent and returns the full analysis plus `data.ratings_warning` (the team-ratings quality verdict, and with `--my-squad` also `data.predictions_stale` / `data.prediction_warnings` / `data.squad_exposure`); with custom analysis off it returns raw 1-5 FPL API difficulty (`data.easy_fixture_runs` only, `metadata.custom_analysis: false`, no ratings and so no warning); `--blanks` bypasses the agent for a schedule-only payload carrying neither the FDR analysis nor `ratings_warning`. `--blanks` mixes confirmed fixtures with predictions; when those predictions are stale, table mode warns on stderr and JSON adds a `fixture_predictions_stale` entry to `metadata.warnings` | Yes | General | No | via-agent |
|
|
44
44
|
| `xg` | Analyse underlying stats: xG, xA, overperformers. The minutes floor and the window scale to the gameweeks played (`min(60 x window, 45 x gameweeks played)`, so 90 minutes over 2 GWs rather than an unreachable 360 over 6); the configured bar binds from GW9, or GW11 for `--all`. JSON `metadata` carries `window_label`, `gameweeks_played` and `min_minutes`, and `metadata.warnings` an `early_season_minutes_floor` notice while the bar is scaled, plus `understat_team_unmatched` for any club Understat carried no players for. When nothing qualifies, `data.empty_reason` says whether the floor (`below_minutes_floor`) or the data (`no_minutes_played`) caused it | Yes | General | No | via-agent |
|
|
45
45
|
| `price-changes` | Show price changes and transfer activity | No | General | No | via-agent |
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [2.7.0] - 2026-09-08
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- never record rank movement out of a league's first gameweek (#325)
|
|
9
|
+
- stop the gameweek review narrative inventing its own counts (#328)
|
|
10
|
+
- keep the settled league position, not a mid-settlement one (#331)
|
|
11
|
+
- keep players who have never appeared off the review's Blankers list (#333)
|
|
12
|
+
- stop draft recaps reading an outbid manager as one who never tried (#334)
|
|
13
|
+
- mark the reader's own row in the saved review's Nearby Rivals table (#335)
|
|
14
|
+
- give managers level on points the same position in the review (#338)
|
|
15
|
+
- record the waiver claims a draft manager lost in the league ledger (#339)
|
|
16
|
+
- fine every manager who ties for last, not just one of them (#340)
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
- surface contested waiver claims in the draft recap (#341)
|
|
21
|
+
|
|
22
|
+
### Other
|
|
23
|
+
|
|
24
|
+
- No AFCON this season
|
|
25
|
+
- add back mention of AFCON transfers
|
|
26
|
+
|
|
4
27
|
## [2.6.1] - 2026-09-07
|
|
5
28
|
|
|
6
29
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fplkit
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.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
|
|
@@ -547,7 +547,7 @@ fpl_cli/
|
|
|
547
547
|
│ ├── contract.py # Runtime tripwires: CSV header checks + empty-output warnings for dataset drift
|
|
548
548
|
│ ├── dataset_fetcher.py # DatasetFetcher (disk cache with ETag/TTL for GitHub CSVs)
|
|
549
549
|
│ ├── fpl.py # FPLClient (main API, caches bootstrap-static)
|
|
550
|
-
│ ├── fpl_draft.py # FPLDraftClient + match_draft_to_main() + the transaction `result` vocabulary (`is_accepted_transaction()` / `
|
|
550
|
+
│ ├── fpl_draft.py # FPLDraftClient + match_draft_to_main() + the transaction `result` vocabulary (`is_accepted_transaction()` / `is_denied_on_the_incoming_player()` / `resolve_lost_claims()`, issues #329, #342). The feed carries denied claims alongside accepted ones, and the two denial codes say which side of the claim was already gone rather than who took it: `di` is the incoming player, `do` the nominated drop. Neither is a defeat on its own -- the engine processes claims league-wide in `index` order, so a manager's own winning claim removes the incoming player exactly as a rival's does, and a manager who wins a player he also listed lower down gets a `di` for the player he just won. What a manager actually lost is therefore only answerable from his whole gameweek, which is `resolve_lost_claims()`: it drops a `di` only where a claim of that manager's had *already taken* the player -- ordering, not end-of-gameweek ownership, since free agency runs after the whole waiver batch and a pickup there cannot have caused an earlier denial -- and returns a player he claimed several times once, at the best priority he spent, the same two rules `contested_draft_claims` states. Consumers go through these rather than comparing `result` themselves, so a move ledger never counts a denied row, an activity question never misses one, and a race a manager won is never reported as one he lost
|
|
551
551
|
│ ├── understat.py # UnderstatClient + match_fpl_to_understat(), understat_club_rows() (the club gate the matcher and `fpl doctor --providers` share), understat_join_warnings(), understat_name_join_stats() (the name-level join rate, the signal a silently unmatched player had none of), decode_entities() (Understat escapes its payload for HTML, so it is decoded once at the fetch boundary), matches_in_season() (the season guard behind get_team())
|
|
552
552
|
│ ├── historical_types.py # Shared dataclasses (SeasonHistory — incl. the four optional per-90 DEF/GK rates only Core-Insights publishes, PlayerProfile, GwTrendProfile) + compute_trend/compute_acceleration/compute_reliability
|
|
553
553
|
│ ├── vaastav.py # VaastavClient (the two oldest seasons of the window via DatasetFetcher)
|
|
@@ -594,7 +594,7 @@ fpl_cli/
|
|
|
594
594
|
│ └── types.py # TypedDicts: CaptainCandidate, WaiverTarget, EnrichedPlayer, etc.
|
|
595
595
|
├── prompts/
|
|
596
596
|
│ ├── scout.py # ScoutAgent system/user prompts
|
|
597
|
-
│ ├── review.py # Review research + synthesis prompts. Squad and transfer lines in the synthesis data block carry each player's full club name, and the hard constraints bind club affiliation and blanket scored/blanked claims to that supplied data — an LLM's own club knowledge goes stale every transfer window. The synthesis prompt also carries a `<next_gameweek>` block — every club's next fixture and each squad player's positional FDR, built by `_review_next_gameweek` / `_format_next_gameweek` — and the "Next Week" instruction switches on whether it is there: grounded advice when it is, fixture-independent observations pointing at `fpl gw-prep` when it is not, because a results-only review was issuing confident fixture-dependent calls with no fixture data behind them (#191), with `check_next_week_grounding` checking the answer back against that block -- a cited FDR figure the block never printed, or any fixture claim on a run that had no block -- so the grounding claim is not left resting on prompt prose alone, the way the research half's rules are backed by `validate_research_teams
|
|
597
|
+
│ ├── review.py # Review research + synthesis prompts. Squad and transfer lines in the synthesis data block carry each player's full club name, and the hard constraints bind club affiliation and blanket scored/blanked claims to that supplied data — an LLM's own club knowledge goes stale every transfer window. The synthesis prompt also carries a `<next_gameweek>` block — every club's next fixture and each squad player's positional FDR, built by `_review_next_gameweek` / `_format_next_gameweek` — and the "Next Week" instruction switches on whether it is there: grounded advice when it is, fixture-independent observations pointing at `fpl gw-prep` when it is not, because a results-only review was issuing confident fixture-dependent calls with no fixture data behind them (#191), with `check_next_week_grounding` checking the answer back against that block -- a cited FDR figure the block never printed, or any fixture claim on a run that had no block -- so the grounding claim is not left resting on prompt prose alone, the way the research half's rules are backed by `validate_research_teams`, which folds both the supplied name and the model's cell through `normalise_name` before the allowlist search so a reformat is not read as an invention, and leaves a `[table trimmed: N unlisted row(s) removed]` line under a table it did shorten — the strip notice otherwise reaching stderr and `--debug` only, both gone by the time the markdown is read (#343). Also the synthesis half's post-generation guard (`check_synthesis_completeness`, `required_synthesis_sections`), which reads the required `## ` headings back off the prompt that was actually sent rather than restating them as a constant. The research half's counts are grounded the same way: `fixture_aggregates()` computes the fixture, goal, clean-sheet and goalless-draw totals once, `FixtureAggregates.summary_line()` pins them at the top of the GW Results block as the narrative's only permitted source, and `validate_research_counts` checks the narrative back against the same object — a count the prompt pins cannot drift from the count the check enforces. It rewrites a contradicting figure in place rather than scrubbing the sentence, and corrects one only when neither its sentence nor the one before it names anyone the count could belong to — a club, a nickname, a player, a pronoun standing in for one — because a round-wide total is exactly the sentence that names nobody. The guard errs towards leaving prose alone: a missed correction ships the model's own words, while a wrong one fabricates a claim in prose that was true. That blind spot is why the check was needed at all — the narrative that reported six clean sheets as four (#324) named no player, so all three name-based validators passed it
|
|
598
598
|
│ ├── returnees.py # Return-intel search prompt for `fpl returnees --enrich` (one player per query, so a citation list belongs to a single player)
|
|
599
599
|
│ └── league_recap.py # League recap synthesis prompts, including the anchored League History section, its never-infer-history rule (emitted even when the pack is empty), and the season-phase framing instruction. The GW Standings section leads with an explicit "Previous gameweek's leader" fact (issue #189) so a "topped the table"/"fell from the top" claim is checkable against a stated name instead of inferred from the Prev column or the size of a fall, and the rules forbid merging two managers' separate League History entries into a shared record or "club". `collect_player_clubs()` regroups the club each squad/transfer row already resolved off its `team_id` by the name the prose uses, feeding both a `## Player Clubs` roster and the club printed inline on each captain group, so club claims bind to supplied data instead of recall; a name the data gives two clubs for is dropped rather than guessed at, since the prose can't tell the two players apart. Transfers get the same full-roster treatment as captains and chips (issue #71): `format_recap_transfers_context()` lists every manager who transferred with each move, the hit and the post-hit net, names who made none, and keeps a manager whose moves the collector could not capture among the movers with the moves marked unknown, so the rule pins transfer narration to that section rather than to the two award winners — the only transfer data the model used to see, which it read as the whole story. `format_recap_waivers_context()` is the draft half (issue #301): every waiver and free-agent move as it was made, never chain-contracted (that is the awards' display compression, not a record of activity), each tagged by kind through the shared `draft_transaction_kind_label()` / `draft_transaction_kind_counts()` in `_league_recap_types.py` so the roster and the waiver awards never describe one move in two vocabularies; the rule names that section as draft's source of truth and only falls back to "say nothing about moves" when neither roster is present. It states three groups rather than two (issue #329): movers, the managers who claimed a player and lost him to a rival, and the managers who did neither. A waiver is a competition, so an outbid manager ends the gameweek with no accepted move and used to be indistinguishable from one who submitted nothing -- the editorial read that absence as inactivity and assigned a motive for it, so the rule now forbids calling anyone in the outbid group idle. The section closes with the gameweek's contested players (issue #330) — one sentence per player more than one manager claimed, from `contested_draft_claims()` / `format_contested_claim()` in `_league_recap_types.py`, the same sentence the Most Contested award prints for a pile-up of `MOST_CONTESTED_MIN_CLAIMANTS` or more that a recapped manager won (a race nobody can be seen to have won is context, never the award), with a rule pinning who-wanted-whom to those lines and limiting what may be said of anyone's waiver order to the winner having stood higher in it. `format_recap_prior_seasons_context()` (issue #131) is the opener's one source of history: each classic manager's FPL-wide seasons before this one, folded once by `summarise_prior_seasons()` (`_league_recap_types.py`) so the saved report and the prompt read the same lines, with an explicit count, the no-record and unfetched managers named rather than omitted, and a rule pinning tenure to FPL-wide phrasing — entry and league IDs are reissued each season, so "Nth season in this league" is unknowable and the section says so
|
|
600
600
|
├── parsers/
|
|
@@ -608,7 +608,7 @@ fpl_cli/
|
|
|
608
608
|
├── gameweek.py # is_opening_gameweek(gw) — shared GW1 check (transfers, waivers and league tables don't exist yet)
|
|
609
609
|
├── markdown.py # HeadingMatcher, find_section, section_body, leaf_body, fence_flags — fence-aware markdown section location tolerant of LLM heading drift; shared by the gw-prep validator scripts and fpl_cli.prompts.review. Also unescape_specials/find_entities — repair and reporting for markdown that arrived HTML-escaped
|
|
610
610
|
├── teams.py # describe_team_set_mismatch — diff a per-team config against the live team list (promotion/relegation drift)
|
|
611
|
-
├── text.py # strip_diacritics (name matching across sources)
|
|
611
|
+
├── text.py # strip_diacritics (name matching across sources). normalise_name folds the rest of what a retyped name varies by — the apostrophe family onto U+0027, whitespace runs (NFKC, so non-breaking spaces too) and the space after an initial ("B. Fernandes" → "B.Fernandes") — on top of the diacritic strip and lowercasing. Apply it to both sides of a comparison. Shared by everywhere the review matches an LLM-written name against an API one: `validate_research_teams` / `ensure_top_performer_first` in prompts/review.py, and `_normalise_name` in cli/_review_summarisation.py, which layers its parenthetical and leading-initial strips on top for `--compare-recs`. Neither a `\b`-anchored regex nor an equality test survives any of these differences: they cost a valid row in the review's Disappointments table, and would read a captain that was in fact fielded as a recommendation ignored (#343). Also ordinal_suffix/ordinal_word
|
|
612
612
|
└── time.py # format_deadline/format_kickoff/format_generated_at — UK local (Europe/London, auto GMT↔BST). Canonical formatter for every user-facing timestamp.
|
|
613
613
|
|
|
614
614
|
platformdirs (user_config_dir / user_data_dir) # macOS: ~/Library/Application Support/fpl-cli/
|
|
@@ -712,5 +712,5 @@ A settings block present but empty — every line under it commented out — par
|
|
|
712
712
|
- **One allocation and one merge for the historical sources.** `historical_season_windows()` (`api/historical.py`) is the only place that decides which of the four seasons a profile spans come from Core-Insights (the newest two, all it publishes) and which from vaastav (the two before), and `make_historical_provider()` and `fpl doctor --providers` both read it. `merge_season_histories()` then keeps one row per `(element_code, season)` with Core-Insights outranking vaastav and announces any repeat, so provenance is a decision rather than two window expressions happening to be disjoint: before #101 a widened window would have doubled a season in every per-season mean and taken two of the reliability window's three slots without raising.
|
|
713
713
|
- **Season data frozen in code is surfaced, not guarded.** Every other season-derived artefact rolls or invalidates itself — the historical window rolls at the July cutover, a stale `player_prior.yaml` or `returnee_snapshot.json` is discarded and rebuilt. The calibrated quality ceilings cannot: they are measurements of a completed season's player cohort, and only re-running `scripts/calibrate_quality_ceilings.py --write` against a newly completed season can replace them. So the generated block records `CALIBRATION_SEASON` beside the fingerprint and `fpl doctor` compares it against the newest completed season (#128). It reports, rather than fails, on purpose: anchors from last season are correct for the whole season being played, so the mid-season state is not a defect, and a rollover changes the truth of the block with no commit behind it — a CI guard would have nothing to fire on until someone touched the code, which is exactly the case it is not for.
|
|
714
714
|
- **Who a player *was* is remembered; where he played is re-derived.** A replay resolves every pick against today's bootstrap, so a player transferred or renamed since lands on a past gameweek's row wearing his current club and current name (issue #169). `_carry_recorded_identity` answers that by keeping what the gameweek already recorded, which works only where something *was* recorded — and two cases have nothing: a first capture, and a coarse row being upgraded, since the coarse tier stores headline numbers and no squad at all. The second is the documented workflow, so a mid-season first run plus `--backfill-detail` writes today's clubs across a whole season (issue #177). `services/player_clubs.py` closes the club half from the gameweek itself instead of from a prior row: the live payload records which fixtures each player's club played, a fixture names its two clubs, and the intersection is where he was — settled outright by a double, narrowed to a pair by a single, and paid for with one `element-summary` request only where today's club is absent from that pair, which is proof he moved and the one case the pair cannot resolve. Because it is *derived* rather than remembered, it also supersedes a recorded club, where name and position are only ever carried: a row a first capture or a coarse upgrade wrote holds today's club as fact, so carrying it forward would preserve the mistake permanently — the same earliest-not-winner lesson as #169, applied to a value the gameweek can recompute. Which makes the *precision* of "derived" load-bearing in both directions. A derivation must supersede a record even when it agrees with today's club, or a player who left and came back keeps a stale row forever; and an assumption must never be called a derivation, or today's club overwrites a correctly recorded one — which is why the ambiguous single-fixture case answers nothing rather than answering "he has not moved". Name and position stay carry-forward-only because fixtures say nothing about them, and a blanking club has nothing to intersect; both fall back to the recorded value, then to today's. A lost waiver claim names two players the way a move does and a replay restamps both the same way, so it is carried by the same rule — read off the earliest line that recorded any claims rather than the earliest squad line, since a gameweek captured before schema version 6 holds its squad on a line that records nothing about them (issue #332).
|
|
715
|
-
- **Absence from the accepted set is not inactivity.** The draft transaction feed carries every claim the league processed, and a claim a rival won is evidence the manager was active — but the ledger held completed moves alone, so a manager whose whole gameweek was one lost claim was written into the permanent record as having sat it out, and the recap named real people as idle on that basis (issues #329, #332). The row now carries `lost_claims` beside `transactions` rather than a result code inside it: nothing moved, so a lost claim has no points and no net, and folding it into a list every consumer reads as completed moves — the waiver streaks sum `net` over it, the identity carry pairs it slot for slot — would have made each of them learn to skip entries. Only
|
|
715
|
+
- **Absence from the accepted set is not inactivity.** The draft transaction feed carries every claim the league processed, and a claim a rival won is evidence the manager was active — but the ledger held completed moves alone, so a manager whose whole gameweek was one lost claim was written into the permanent record as having sat it out, and the recap named real people as idle on that basis (issues #329, #332). The row now carries `lost_claims` beside `transactions` rather than a result code inside it: nothing moved, so a lost claim has no points and no net, and folding it into a list every consumer reads as completed moves — the waiver streaks sum `net` over it, the identity carry pairs it slot for slot — would have made each of them learn to skip entries. Only genuine defeats are recorded, which neither denial code identifies by itself: `do` is the cascade behind a success, and `di` covers one too, since the manager's own winning claim removes the incoming player just as a rival's does (issue #342). `resolve_lost_claims()` settles both from the manager's whole gameweek, on the order the league processed it rather than on who owned the player by the end — so a waiver lost to a rival who then dropped him, and signed in free agency afterwards, stays the defeat it was — and dedupes a target he offered against several drops down to the one player he wanted. The field is three-state on purpose: a list is what the feed held, `[]` included, and `None` says nothing was recorded — a classic row, an unknown row, or a row written before the field existed. That last state is what makes the backfill work without a migration: `coverage()` counts such rows on draft partitions, `_gaps` hands those gameweeks to the detailed tier behind `--backfill-detail`, the replay re-records them from the feed while it still serves the season, and because `[]` differs in content from `None` the superseding line lands once and the target converges. Presence is carried as well as identity: the feed is a log, so a finished gameweek's claims are all in it and a replay that finds fewer has lost some — a player it can no longer place, or a gameweek the feed has stopped serving — and `_carry_recorded_claims` keeps the longest recorded list rather than letting the remnant supersede it, while a longer replayed list still lands as the correction it is.
|
|
716
716
|
- **A row that knows less never supersedes one that knows more.** The ledger is append-only and reads take the newest row per manager, so a capture that writes a null over a recorded value destroys it as surely as an edit would — and permanently. Draft is where that bites: it has no per-manager history endpoint and its standings always describe the current gameweek, so a *replayed* draft gameweek derives no league position and no cumulative total at all. Writing those rows out cost the ledger every position that gameweek held, which is the one input the streak and season-count projections have, and each replay left a full set of dead lines behind (issue #223). Three things stop it. `_carry_recorded_standings` fills a null from the *latest-captured* line that recorded the field — read off the whole file rather than off `resolved_gameweek`'s winner, and ordered by `captured_at` rather than by position in it, since `append_rows` skips on tier rank alone and so does not guarantee the two agree — the same read-past-the-winner move `_carry_recorded_identity` makes, and for the same reason: by the time anyone notices, the degraded row is the winner. What keeps a degraded row out is the value being non-null, not the line being earliest, and that holds whichever way the pass runs; direction only settles which recorded value wins when several disagree, and there the later one is right — a gameweek captured repeatedly while bonus and late results were still landing holds a stale position in its first capture and the settled one in its last (issue #320). Identity stays earliest-first, because a name and a position do not settle over a gameweek the way a table does. `_fill_draft_standings` reconstructs what the ledger can supply on its own, summing a cumulative total from earlier gameweeks and ranking from it (at the league's start gameweek the total *is* the gameweek score, so `gross_points` alone settles that table), and derives a whole table or none of it. Every manager needs a total, because ranking the subset that summed cleanly would renumber everyone below whoever was left out; and no row may already carry a position, because `_assign_cohort_ranks` fills only the nulls, so a cohort part-carried and part-re-derived is ranked against two tables at once and lands two managers on the same place with nobody on the next. Draft breaks a head-to-head tie on points-for, which a cumulative total cannot reproduce, so those two rankings disagree in the ordinary case rather than only on a tie — and a null, which every streak condition holds across, beats a wrong position recorded as fact. `_repair_recorded_standings` then sweeps already-damaged gameweeks, ascending, on every run: it re-fetches nothing, so it needs neither `--backfill-detail` nor a request, and it is the only path that heals a ledger damaged before this existed. Filling only nulls is what makes all three idempotent — a genuine correction still lands, and a replay that reproduces a gameweek exactly now writes no line at all.
|
|
@@ -750,7 +750,7 @@ fpl review --dry-run # Build prompts without calling LLMs
|
|
|
750
750
|
|
|
751
751
|
**Classic:** team summary, player-by-player breakdown (captain doubled/tripled), transfer assessment, league standings with nearby rivals, best/worst performers.
|
|
752
752
|
|
|
753
|
-
**League position:** your own position and the `Pos` column of Nearby Rivals are competition-ranked from the cumulative totals, the same numbering `fpl league-recap` writes to the ledger: managers level on points share a place and the next distinct total skips the places they consumed (1, 2, 2, 4). The standings API hands out strictly sequential ranks instead, splitting a tie on fewest transfers season-to-date — a figure no column here shows — so the same gameweek used to place the same tie differently depending on which report you read.
|
|
753
|
+
**League position:** your own position and the `Pos` column of Nearby Rivals are competition-ranked from the cumulative totals, the same numbering `fpl league`'s table shows and `fpl league-recap` writes to the ledger: managers level on points share a place and the next distinct total skips the places they consumed (1, 2, 2, 4). The standings API hands out strictly sequential ranks instead, splitting a tie on fewest transfers season-to-date — a figure no column here shows — so the same gameweek used to place the same tie differently depending on which report you read.
|
|
754
754
|
|
|
755
755
|
**Draft:** squad breakdown, transaction assessment, league standings, best/worst performers.
|
|
756
756
|
|
|
@@ -1043,9 +1043,12 @@ priority it was made at. They sit apart from `transactions`, which stays the lis
|
|
|
1043
1043
|
that happened — nothing moved here, so there are no points on either side and no net —
|
|
1044
1044
|
because a manager whose whole gameweek was one claim they lost has no completed move,
|
|
1045
1045
|
and a ledger holding moves alone wrote them into the permanent record as having sat the
|
|
1046
|
-
week out. Only claims
|
|
1047
|
-
own
|
|
1048
|
-
|
|
1046
|
+
week out. Only claims a rival won are recorded, which the feed's denial codes do not
|
|
1047
|
+
identify on their own: a claim denied because the manager's own earlier accepted claim
|
|
1048
|
+
had already dropped the nominated player is the cascade behind a success, and so is a
|
|
1049
|
+
claim denied because he had just won the incoming player himself — both are read against
|
|
1050
|
+
his whole gameweek and neither appears. A player he claimed several times against
|
|
1051
|
+
different drops is one claim, at the best priority he spent on him (see
|
|
1049
1052
|
[Conditional chains and outcomes](fpl-rules.md#waivers)). A replay that finds fewer
|
|
1050
1053
|
claims than the gameweek recorded keeps the recorded list and says so
|
|
1051
1054
|
(`league_history_claims_carried`): a claim whose player can no longer be placed is
|
|
@@ -189,15 +189,21 @@ Processed **24 hours before the GW deadline**. After processing, **free agency**
|
|
|
189
189
|
|
|
190
190
|
**Locked players** (unavailable for waivers): players added to the game within 24 hours of the waiver deadline, or players recently dropped from another squad. Locked players become available at the next waiver deadline.
|
|
191
191
|
|
|
192
|
-
**Conditional chains and outcomes.** A manager submits several claims in their own priority order
|
|
192
|
+
**Conditional chains and outcomes.** A manager submits several claims in their own priority order. Claims may share a drop, and may name one target against several drops, so only one of a group is ever expected to land. Claims are processed league-wide in a single order, and each ends in one of three states:
|
|
193
193
|
|
|
194
194
|
| Outcome | Meaning |
|
|
195
195
|
|---|---|
|
|
196
196
|
| Accepted | The claim was processed; the incoming player joined the squad and the nominated player was dropped |
|
|
197
|
-
|
|
|
198
|
-
| Drop already used | An earlier accepted claim by the same manager had already dropped the nominated player, so this one could not be processed.
|
|
197
|
+
| Incoming player gone | Someone had already taken him by the time this claim processed, so it could not be filled |
|
|
198
|
+
| Drop already used | An earlier accepted claim by the same manager had already dropped the nominated player, so this one could not be processed. It cannot occur without that earlier accepted claim |
|
|
199
199
|
|
|
200
|
-
|
|
200
|
+
Neither denial says on its own whether the manager lost anything, because "someone" in the second row includes the manager himself. A manager who wins a player at one priority and listed him again lower down gets an "incoming player gone" for the player he just won — the same self-cascade the third row describes, arriving on the other side of the claim. Only a denial where a **different** manager took the player is a defeat.
|
|
201
|
+
|
|
202
|
+
So a claim's outcome is only readable against the manager's whole gameweek, never from the claim alone. Two rules follow: a denial for a player one of the manager's own claims had **already taken** is his own cascade, not a loss; and a player he claimed several times and lost was wanted once, not once per drop he offered.
|
|
203
|
+
|
|
204
|
+
The first rule turns on order, not on who ends the gameweek owning the player. Waivers process as one batch and free agency opens only afterwards, so a manager who loses a waiver to a rival, and signs the same player once that rival drops him, still lost the waiver.
|
|
205
|
+
|
|
206
|
+
Only accepted claims changed a squad, so only they belong in a record of moves. But a manager who submitted claims and genuinely lost them all was active, and reading their absence from the accepted set as inactivity misreports them.
|
|
201
207
|
|
|
202
208
|
### Free Agents
|
|
203
209
|
Unclaimed players can be picked up first-come, first-served in the **24-hour window before each GW deadline**. Free agent pickups don't affect waiver priority.
|
|
@@ -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 = '2.7.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 7,
|
|
21
|
+
__version__ = version = '2.7.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 7, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -17,22 +17,28 @@ logger = logging.getLogger(__name__)
|
|
|
17
17
|
BASE_URL = "https://draft.premierleague.com/api"
|
|
18
18
|
|
|
19
19
|
# The Draft API's transaction `result` codes. Every row the league processed
|
|
20
|
-
# carries one, and the two denial codes
|
|
21
|
-
#
|
|
22
|
-
# made" is wrong for one of them and right for the other (issue #329).
|
|
20
|
+
# carries one, and the two denial codes say which side of the claim was
|
|
21
|
+
# already gone -- not who took it (issues #329, #342).
|
|
23
22
|
#
|
|
24
23
|
# `a` -- the league processed the claim into a completed move.
|
|
25
|
-
# `di` -- denied
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
24
|
+
# `di` -- denied on the *incoming* side: the player was gone when this claim
|
|
25
|
+
# processed. Usually a rival won him, which is a genuine competitive
|
|
26
|
+
# loss. But the engine processes claims globally in `index` order and
|
|
27
|
+
# a manager's own earlier accepted claim removes him exactly as a
|
|
28
|
+
# rival's does, so a manager who wins a player and also listed him
|
|
29
|
+
# lower down gets a `di` for the player he just won.
|
|
30
|
+
# `do` -- denied on the *outgoing* side: the player nominated as the drop had
|
|
31
|
+
# already been used as the drop in an earlier accepted claim by the
|
|
32
|
+
# same manager. It cannot occur without that earlier accepted claim,
|
|
33
|
+
# so a `do` row never stands alone as a manager's only activity.
|
|
34
|
+
#
|
|
35
|
+
# Both denials therefore cover a self-cascade as well as a real defeat, which
|
|
36
|
+
# is why the code alone cannot say whether a manager lost anything: managers
|
|
37
|
+
# submit several claims sharing one drop slot, or naming one target against
|
|
38
|
+
# several drops, expecting only one to land. `resolve_lost_claims()` answers
|
|
39
|
+
# that from the manager's whole gameweek; a single row cannot.
|
|
34
40
|
DRAFT_TXN_ACCEPTED = "a"
|
|
35
|
-
|
|
41
|
+
DRAFT_TXN_DENIED_PLAYER_GONE = "di"
|
|
36
42
|
DRAFT_TXN_DROP_ALREADY_USED = "do"
|
|
37
43
|
|
|
38
44
|
|
|
@@ -45,14 +51,89 @@ def is_accepted_transaction(txn: Mapping[str, Any]) -> bool:
|
|
|
45
51
|
return txn.get("result") == DRAFT_TXN_ACCEPTED
|
|
46
52
|
|
|
47
53
|
|
|
48
|
-
def
|
|
49
|
-
"""Whether this row
|
|
54
|
+
def is_denied_on_the_incoming_player(txn: Mapping[str, Any]) -> bool:
|
|
55
|
+
"""Whether this row was denied because the incoming player was gone.
|
|
50
56
|
|
|
51
57
|
True for `di` alone. A `do` row is deliberately excluded: counting it as
|
|
52
58
|
a failed attempt would misreport the manager whose *winning* claim caused
|
|
53
59
|
it, which is the misattribution this classification exists to prevent.
|
|
60
|
+
|
|
61
|
+
Row-local, and so not the same question as "did this manager lose the
|
|
62
|
+
player" -- his own winning claim makes him gone too. Callers wanting that
|
|
63
|
+
want `resolve_lost_claims()`.
|
|
64
|
+
"""
|
|
65
|
+
return txn.get("result") == DRAFT_TXN_DENIED_PLAYER_GONE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def draft_claim_priority_rank(priority: int | None) -> tuple[int, int]:
|
|
69
|
+
"""Sort key placing a numbered priority before none at all, 1 first."""
|
|
70
|
+
return (1, 0) if priority is None else (0, priority)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _processing_position(txn: Mapping[str, Any], arrival: int) -> tuple[int, int, int]:
|
|
74
|
+
"""Where one row sits in the order the league processed the gameweek.
|
|
75
|
+
|
|
76
|
+
Waivers run first, as a single batch the engine works through in `index`
|
|
77
|
+
order; free agency opens once that batch is done and its rows carry no
|
|
78
|
+
index at all. `arrival` is the row's place in the feed, which orders the
|
|
79
|
+
rows an index cannot separate.
|
|
80
|
+
"""
|
|
81
|
+
index = txn.get("index")
|
|
82
|
+
return (0, index, arrival) if isinstance(index, int) else (1, 0, arrival)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_lost_claims(txns: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
86
|
+
"""The claims one manager genuinely lost, from all their rows for one
|
|
87
|
+
gameweek -- the question no single row can answer (issue #342).
|
|
88
|
+
|
|
89
|
+
Two things a row-local read gets wrong, both consequences of managers
|
|
90
|
+
submitting conditional chains rather than independent claims:
|
|
91
|
+
|
|
92
|
+
- A `di` for a player this manager had *already taken* is his own cascade,
|
|
93
|
+
not a defeat. He listed the same target twice with different drops, one
|
|
94
|
+
landed, and every later claim for him was then denied on the incoming
|
|
95
|
+
side. Reporting it inverts the outcome for the manager who won the race.
|
|
96
|
+
- A player he claimed several times and lost was wanted once, so he is
|
|
97
|
+
returned once, at the best priority he spent on him. Otherwise one
|
|
98
|
+
target across three drops reads as three separate defeats.
|
|
99
|
+
|
|
100
|
+
"Already taken" is a question about order, not merely about owning him by
|
|
101
|
+
the end of the gameweek: only a claim that landed *before* the denial can
|
|
102
|
+
have caused it. Free agency opens after the whole waiver batch, so a
|
|
103
|
+
manager who loses a waiver to a rival and signs the same player as a free
|
|
104
|
+
agent once the rival drops him still lost that waiver, and the loss is
|
|
105
|
+
still recorded -- clearing it on the later pickup would erase a real
|
|
106
|
+
defeat and contradict the case `contested_draft_claims` documents.
|
|
107
|
+
|
|
108
|
+
Both rules are the ones that function already states and works to; this
|
|
109
|
+
brings the stored claims into line with them, so the two layers cannot
|
|
110
|
+
disagree about what a manager lost. Order follows the feed, by the row
|
|
111
|
+
kept for each player.
|
|
54
112
|
"""
|
|
55
|
-
|
|
113
|
+
# The earliest point at which a claim of this manager's took each player.
|
|
114
|
+
taken_at: dict[Any, tuple[int, int, int]] = {}
|
|
115
|
+
for arrival, txn in enumerate(txns):
|
|
116
|
+
if not is_accepted_transaction(txn):
|
|
117
|
+
continue
|
|
118
|
+
player = txn.get("element_in")
|
|
119
|
+
at = _processing_position(txn, arrival)
|
|
120
|
+
if player not in taken_at or at < taken_at[player]:
|
|
121
|
+
taken_at[player] = at
|
|
122
|
+
|
|
123
|
+
best: dict[Any, dict[str, Any]] = {}
|
|
124
|
+
for arrival, txn in enumerate(txns):
|
|
125
|
+
player = txn.get("element_in")
|
|
126
|
+
if player is None or not is_denied_on_the_incoming_player(txn):
|
|
127
|
+
continue
|
|
128
|
+
taken = taken_at.get(player)
|
|
129
|
+
if taken is not None and taken < _processing_position(txn, arrival):
|
|
130
|
+
continue
|
|
131
|
+
known = best.get(player)
|
|
132
|
+
if known is None or draft_claim_priority_rank(txn.get("priority")) < draft_claim_priority_rank(
|
|
133
|
+
known.get("priority"),
|
|
134
|
+
):
|
|
135
|
+
best[player] = txn
|
|
136
|
+
return list(best.values())
|
|
56
137
|
|
|
57
138
|
|
|
58
139
|
class FPLDraftClient:
|
|
@@ -2033,8 +2033,8 @@ async def collect_draft_recap_data(
|
|
|
2033
2033
|
from fpl_cli.api.fpl_draft import (
|
|
2034
2034
|
FPLDraftClient,
|
|
2035
2035
|
is_accepted_transaction,
|
|
2036
|
-
is_lost_claim,
|
|
2037
2036
|
match_draft_to_main,
|
|
2037
|
+
resolve_lost_claims,
|
|
2038
2038
|
)
|
|
2039
2039
|
from fpl_cli.models.player import POSITION_MAP
|
|
2040
2040
|
|
|
@@ -2073,15 +2073,22 @@ async def collect_draft_recap_data(
|
|
|
2073
2073
|
# but who was active cannot -- a manager whose only claim a rival won
|
|
2074
2074
|
# has no accepted row and used to be indistinguishable from one who
|
|
2075
2075
|
# submitted nothing (issue #329). So both are kept, separately.
|
|
2076
|
+
#
|
|
2077
|
+
# Bucketed by manager *before* either question is asked: what a
|
|
2078
|
+
# manager lost is only answerable from his whole gameweek, since a
|
|
2079
|
+
# claim denied on a player he went on to win is his own cascade
|
|
2080
|
+
# rather than a defeat (issue #342).
|
|
2076
2081
|
txn_response = await draft_client.get_league_transactions(draft_league_id)
|
|
2077
2082
|
all_txns: list[dict[str, Any]] = txn_response.get("transactions", [])
|
|
2078
2083
|
gw_txns = [t for t in all_txns if t.get("event") == gw]
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2084
|
+
by_entry = _bucket_draft_txns_by_league_entry(gw_txns, league_entries)
|
|
2085
|
+
txns_by_entry = {
|
|
2086
|
+
le_id: [t for t in rows if is_accepted_transaction(t)]
|
|
2087
|
+
for le_id, rows in by_entry.items()
|
|
2088
|
+
}
|
|
2089
|
+
lost_by_entry = {
|
|
2090
|
+
le_id: resolve_lost_claims(rows) for le_id, rows in by_entry.items()
|
|
2091
|
+
}
|
|
2085
2092
|
|
|
2086
2093
|
# Fetch picks for each manager
|
|
2087
2094
|
sem = asyncio.Semaphore(_PICKS_CONCURRENCY)
|
|
@@ -6,6 +6,7 @@ from collections.abc import Iterable, Sequence
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import NotRequired, TypedDict
|
|
8
8
|
|
|
9
|
+
from fpl_cli.api.fpl_draft import draft_claim_priority_rank
|
|
9
10
|
from fpl_cli.utils.text import ordinal_word
|
|
10
11
|
|
|
11
12
|
|
|
@@ -656,11 +657,6 @@ def _contested_player_key(
|
|
|
656
657
|
return (move["player_in"], move["player_in_team"])
|
|
657
658
|
|
|
658
659
|
|
|
659
|
-
def _priority_rank(priority: int | None) -> tuple[int, int]:
|
|
660
|
-
"""Sort key placing a numbered priority before none at all, 1 first."""
|
|
661
|
-
return (1, 0) if priority is None else (0, priority)
|
|
662
|
-
|
|
663
|
-
|
|
664
660
|
def _contest_winner(
|
|
665
661
|
accepted: Sequence[tuple[int, str]], claimants: Iterable[RecapContestedClaimant],
|
|
666
662
|
) -> int | None:
|
|
@@ -688,22 +684,27 @@ def contested_draft_claims(
|
|
|
688
684
|
contested first: by claimant count, then by how early the beaten
|
|
689
685
|
managers ranked him (two first-choice claims outrank one), then by name.
|
|
690
686
|
|
|
691
|
-
Regrouped from the manager rows rather than fetched: a
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
687
|
+
Regrouped from the manager rows rather than fetched: a stored lost claim
|
|
688
|
+
is one a rival won, so the gameweek's lost claims, bucketed by player, are
|
|
689
|
+
the races, and the accepted move that brought the same player in names the
|
|
690
|
+
winner (issue #330). Only the stored claims carry that meaning -- the
|
|
691
|
+
feed's `di` code says the incoming player was gone, which the manager's
|
|
692
|
+
own winning claim also causes; `resolve_lost_claims()` settles that
|
|
693
|
+
upstream (issue #342). Read off the raw move list, not
|
|
695
694
|
the awards' chain-contracted one, so a manager who won a player and moved
|
|
696
695
|
him on again the same gameweek still won him.
|
|
697
696
|
|
|
698
697
|
A manager who lost several claims on one player -- a conditional chain
|
|
699
698
|
offering different drops for him -- wanted him once, and is named once,
|
|
700
699
|
at the highest priority they gave him. The winner is never also among
|
|
701
|
-
the beaten:
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
700
|
+
the beaten: a manager who lost the waiver and signed him as a free agent
|
|
701
|
+
later the same gameweek carries both rows. Either way they wanted him
|
|
702
|
+
once and got him, and a player nobody else wanted is then no race at all.
|
|
703
|
+
|
|
704
|
+
Both rules now hold of the stored claims before this runs, since
|
|
705
|
+
`resolve_lost_claims()` applies them at ingestion -- these are kept as
|
|
706
|
+
the guarantee this function owns, so a caller handing it claims from
|
|
707
|
+
anywhere still gets one row per manager per race. Empty for classic, whose rows
|
|
707
708
|
carry no claims.
|
|
708
709
|
"""
|
|
709
710
|
players: dict[_ContestedPlayerKey, RecapDraftLostClaim] = {}
|
|
@@ -719,7 +720,9 @@ def contested_draft_claims(
|
|
|
719
720
|
for claim in m.get("lost_claims") or []:
|
|
720
721
|
key = _contested_player_key(claim)
|
|
721
722
|
known = best.get(key)
|
|
722
|
-
if known is None or
|
|
723
|
+
if known is None or draft_claim_priority_rank(claim["priority"]) < draft_claim_priority_rank(
|
|
724
|
+
known["priority"],
|
|
725
|
+
):
|
|
723
726
|
best[key] = claim
|
|
724
727
|
for key, claim in best.items():
|
|
725
728
|
players.setdefault(key, claim)
|
|
@@ -733,7 +736,7 @@ def contested_draft_claims(
|
|
|
733
736
|
winner = _contest_winner(accepted.get(key, []), wanted.values())
|
|
734
737
|
beaten = sorted(
|
|
735
738
|
(c for position, c in wanted.items() if position != winner),
|
|
736
|
-
key=lambda c: (
|
|
739
|
+
key=lambda c: (draft_claim_priority_rank(c["priority"]), c["manager_name"]),
|
|
737
740
|
)
|
|
738
741
|
if not beaten:
|
|
739
742
|
continue
|
|
@@ -752,7 +755,7 @@ def contested_draft_claims(
|
|
|
752
755
|
|
|
753
756
|
contests.sort(key=lambda c: (
|
|
754
757
|
-c["claimants"],
|
|
755
|
-
tuple(
|
|
758
|
+
tuple(draft_claim_priority_rank(loser["priority"]) for loser in c["losers"]),
|
|
756
759
|
c["player"],
|
|
757
760
|
c.get("player_code") or 0,
|
|
758
761
|
))
|
|
@@ -443,8 +443,13 @@ async def _review_classic_league(
|
|
|
443
443
|
# here records, leaving the same gameweek placing the same tie two
|
|
444
444
|
# different ways depending on whether you read the review or the
|
|
445
445
|
# recap's ledger (#337, the defect #163 and #230 fixed elsewhere).
|
|
446
|
+
#
|
|
447
|
+
# An entry with no total is omitted rather than passed a placeholder,
|
|
448
|
+
# per the helper's contract -- a substituted 0 would tie it with a
|
|
449
|
+
# manager who really has scored 0.
|
|
446
450
|
league_positions = derive_point_in_time_positions([
|
|
447
|
-
(e["entry"], e
|
|
451
|
+
(e["entry"], e["total"]) for e in standings
|
|
452
|
+
if e.get("entry") is not None and e.get("total") is not None
|
|
448
453
|
])
|
|
449
454
|
user_entry = next((e for e in standings if e.get("entry") == entry_id), None)
|
|
450
455
|
if user_entry:
|
|
@@ -57,8 +57,8 @@ async def _review_draft(
|
|
|
57
57
|
from fpl_cli.api.fpl_draft import (
|
|
58
58
|
FPLDraftClient,
|
|
59
59
|
is_accepted_transaction,
|
|
60
|
-
is_lost_claim,
|
|
61
60
|
match_draft_to_main,
|
|
61
|
+
resolve_lost_claims,
|
|
62
62
|
)
|
|
63
63
|
|
|
64
64
|
draft_league_data = None
|
|
@@ -275,8 +275,10 @@ async def _review_draft(
|
|
|
275
275
|
# but a week whose only waiver activity was a claim the
|
|
276
276
|
# user lost is not a week they sat out, and reporting it
|
|
277
277
|
# as "no waivers this week" is the misattribution in
|
|
278
|
-
# issue #329 pointed at the user's own review.
|
|
279
|
-
|
|
278
|
+
# issue #329 pointed at the user's own review. Resolved
|
|
279
|
+
# against the reader's whole gameweek, so a claim denied
|
|
280
|
+
# on a player he went on to win is not one he lost (#342).
|
|
281
|
+
lost_txns = resolve_lost_claims(my_txns)
|
|
280
282
|
|
|
281
283
|
# Collapse same-GW churn using net squad delta (Counter diff).
|
|
282
284
|
# Pair residual adds and drops by (position, web_name) for like-for-like rows.
|
|
@@ -25,7 +25,7 @@ from fpl_cli.services.scoring import ATTACKING_POSITIONS
|
|
|
25
25
|
from fpl_cli.services.team_ratings import api_difficulty_scale, fdr_columns_footer
|
|
26
26
|
from fpl_cli.utils.gameweek import is_opening_gameweek
|
|
27
27
|
from fpl_cli.utils.teams import describe_team_set_mismatch
|
|
28
|
-
from fpl_cli.utils.text import
|
|
28
|
+
from fpl_cli.utils.text import normalise_name
|
|
29
29
|
|
|
30
30
|
if TYPE_CHECKING:
|
|
31
31
|
from fpl_cli.prompts.review import SynthesisCompleteness
|
|
@@ -1273,12 +1273,20 @@ async def _review_llm_summarise(
|
|
|
1273
1273
|
|
|
1274
1274
|
|
|
1275
1275
|
def _normalise_name(name: str) -> str:
|
|
1276
|
-
"""Normalise a player name for fuzzy matching.
|
|
1277
|
-
|
|
1278
|
-
name
|
|
1276
|
+
"""Normalise a player name for fuzzy matching.
|
|
1277
|
+
|
|
1278
|
+
One side of every comparison here is a name an LLM typed into the
|
|
1279
|
+
recommendations markdown and the other is a `web_name` off the API, so the
|
|
1280
|
+
fold has to survive the styling the writer applied on the way -- the
|
|
1281
|
+
apostrophe family and whitespace runs included, via `normalise_name`. It
|
|
1282
|
+
matters more here than in the table guard that shares it: a miss there
|
|
1283
|
+
dropped a row, while a miss here reports a captain you *did* field as a
|
|
1284
|
+
recommendation you ignored, and prices the difference against himself.
|
|
1285
|
+
"""
|
|
1286
|
+
name = normalise_name(name)
|
|
1279
1287
|
name = re.sub(r"\s*\(.*?\)\s*$", "", name) # strip parentheticals
|
|
1280
1288
|
name = re.sub(r"^[a-z]\.\s*", "", name) # strip leading initials
|
|
1281
|
-
return name
|
|
1289
|
+
return name.strip()
|
|
1282
1290
|
|
|
1283
1291
|
|
|
1284
1292
|
def _names_match(a: str, b: str) -> bool:
|
|
@@ -13,6 +13,7 @@ from rich.table import Table
|
|
|
13
13
|
|
|
14
14
|
from fpl_cli.cli._context import Format, console, error_console, fpl_config, get_format, get_settings
|
|
15
15
|
from fpl_cli.cli._helpers import _entry_league_meta, _fetch_standings_with_costs
|
|
16
|
+
from fpl_cli.cli._league_recap_data import derive_point_in_time_positions
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
@@ -58,10 +59,31 @@ def league_command(ctx: click.Context) -> None:
|
|
|
58
59
|
league_name = standings_data.get("league", {}).get("name", "Classic League")
|
|
59
60
|
standings = standings_data.get("standings", {}).get("results", [])
|
|
60
61
|
|
|
62
|
+
# Classic `rank` is strictly sequential, so it splits a
|
|
63
|
+
# points tie on fewest transfers season-to-date -- a figure
|
|
64
|
+
# no column here shows and the ledger does not record.
|
|
65
|
+
# Position the table the way `fpl review` and the ledger
|
|
66
|
+
# do, so the same tie isn't numbered two ways depending on
|
|
67
|
+
# which command the reader runs (#344, the defect #337
|
|
68
|
+
# fixed one surface along). An entry below this page can
|
|
69
|
+
# share a place with the last one on it without changing
|
|
70
|
+
# any number here: a shared place consumes the ones behind
|
|
71
|
+
# it, never the ones above.
|
|
72
|
+
#
|
|
73
|
+
# An entry with no total is omitted rather than passed a
|
|
74
|
+
# placeholder, per the helper's contract: a substituted 0
|
|
75
|
+
# would tie it with a manager who really has scored 0, and
|
|
76
|
+
# an explicit null would take the whole section down here
|
|
77
|
+
# rather than at the row it belongs to.
|
|
78
|
+
league_positions = derive_point_in_time_positions([
|
|
79
|
+
(e["entry"], e["total"]) for e in standings
|
|
80
|
+
if e.get("entry") is not None and e.get("total") is not None
|
|
81
|
+
])
|
|
82
|
+
|
|
61
83
|
# Find user's entry
|
|
62
84
|
user_entry = next((e for e in standings if e.get("entry") == entry_id), None)
|
|
63
85
|
if user_entry:
|
|
64
|
-
user_rank =
|
|
86
|
+
user_rank = league_positions.get(entry_id, "?")
|
|
65
87
|
user_total = user_entry.get("total", 0)
|
|
66
88
|
user_gw_pts = user_entry.get("event_total", 0)
|
|
67
89
|
# `standings` is one 50-entry page; a league bigger than
|
|
@@ -87,7 +109,7 @@ def league_command(ctx: click.Context) -> None:
|
|
|
87
109
|
table.add_column("Total", justify="right")
|
|
88
110
|
|
|
89
111
|
for entry in standings:
|
|
90
|
-
rank = str(entry.get("
|
|
112
|
+
rank = str(league_positions.get(entry.get("entry"), "?"))
|
|
91
113
|
name = entry.get("player_name", "Unknown")
|
|
92
114
|
gw_pts = entry.get("event_total", 0)
|
|
93
115
|
total = entry.get("total", 0)
|