Refactored monolithic cli.py into modular architecture: - cli_common.py: shared state, console, output helpers - cli_transactions.py: list + simulate (compliance checker) - cli_injuries.py: injury listing with team/active filters - cli_stats.py: batting/pitching leaderboards - cli_results.py: game results - cli_schedule.py: game schedules Also: team get now shows salary_cap, SKILL.md fully updated with CLI docs, flag ordering warning, and compliance workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
468 lines
15 KiB
Python
468 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Major Domo CLI - Transaction Operations
|
|
|
|
Commands for listing and filtering player transactions (trades, waivers, etc.).
|
|
"""
|
|
|
|
from typing import Annotated, List, Optional
|
|
|
|
import typer
|
|
from rich.panel import Panel
|
|
from cli_common import (
|
|
state,
|
|
console,
|
|
output_json,
|
|
output_table,
|
|
handle_error,
|
|
get_season,
|
|
)
|
|
|
|
transactions_app = typer.Typer(
|
|
help="Transaction operations",
|
|
invoke_without_command=True,
|
|
no_args_is_help=False,
|
|
)
|
|
|
|
|
|
@transactions_app.callback()
|
|
def transactions_callback(
|
|
ctx: typer.Context,
|
|
team: Annotated[
|
|
Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation")
|
|
] = None,
|
|
week: Annotated[
|
|
Optional[int], typer.Option("--week", "-w", help="Filter by specific week")
|
|
] = None,
|
|
week_start: Annotated[
|
|
Optional[int], typer.Option("--week-start", help="Start week for range")
|
|
] = None,
|
|
week_end: Annotated[
|
|
Optional[int], typer.Option("--week-end", help="End week for range")
|
|
] = None,
|
|
player: Annotated[
|
|
Optional[str], typer.Option("--player", "-p", help="Filter by player name")
|
|
] = None,
|
|
season: Annotated[
|
|
Optional[int], typer.Option("--season", "-s", help="Season number")
|
|
] = None,
|
|
limit: Annotated[
|
|
int, typer.Option("--limit", "-n", help="Max results to display")
|
|
] = 50,
|
|
include_cancelled: Annotated[
|
|
bool, typer.Option("--include-cancelled", help="Include cancelled transactions")
|
|
] = False,
|
|
):
|
|
"""
|
|
List transactions with optional filters.
|
|
|
|
By default, shows recent non-cancelled transactions for the current season.
|
|
Use filters to narrow down results.
|
|
|
|
Examples:
|
|
majordomo transactions --team CAR
|
|
majordomo transactions --week 5
|
|
majordomo transactions --week-start 1 --week-end 4
|
|
majordomo transactions --player "Mike Trout"
|
|
"""
|
|
# Only invoke if no subcommand was called
|
|
if ctx.invoked_subcommand is not None:
|
|
return
|
|
|
|
try:
|
|
season = get_season(season)
|
|
|
|
# Handle week vs week_start/week_end
|
|
if week is not None:
|
|
week_start = week
|
|
week_end = week
|
|
|
|
# Build filter parameters
|
|
team_list = [team] if team else None
|
|
player_list = [player] if player else None
|
|
|
|
# Get transactions from API
|
|
transactions = state.api.get_transactions(
|
|
season=season,
|
|
team_abbrev=team_list,
|
|
week_start=week_start,
|
|
week_end=week_end,
|
|
player_name=player_list,
|
|
cancelled=True if include_cancelled else False,
|
|
)
|
|
|
|
# Filter out cancelled transactions unless explicitly requested
|
|
if not include_cancelled:
|
|
transactions = [t for t in transactions if not t.get("cancelled", False)]
|
|
|
|
# Limit results
|
|
transactions = transactions[:limit]
|
|
|
|
if state.json_output:
|
|
output_json(transactions)
|
|
return
|
|
|
|
if not transactions:
|
|
console.print(
|
|
f"[yellow]No transactions found for the given filters (Season {season})[/yellow]"
|
|
)
|
|
return
|
|
|
|
# Build table rows
|
|
rows = []
|
|
for t in transactions:
|
|
player_dict = t.get("player", {})
|
|
oldteam_dict = t.get("oldteam", {})
|
|
newteam_dict = t.get("newteam", {})
|
|
|
|
player_name = (
|
|
player_dict.get("name", "N/A")
|
|
if isinstance(player_dict, dict)
|
|
else "N/A"
|
|
)
|
|
old_abbrev = (
|
|
oldteam_dict.get("abbrev", "N/A")
|
|
if isinstance(oldteam_dict, dict)
|
|
else "N/A"
|
|
)
|
|
new_abbrev = (
|
|
newteam_dict.get("abbrev", "N/A")
|
|
if isinstance(newteam_dict, dict)
|
|
else "N/A"
|
|
)
|
|
|
|
rows.append(
|
|
[
|
|
t.get("week", ""),
|
|
player_name,
|
|
old_abbrev,
|
|
new_abbrev,
|
|
t.get("moveid", ""),
|
|
]
|
|
)
|
|
|
|
# Build title with filters
|
|
title_parts = [f"Transactions (Season {season})"]
|
|
if team:
|
|
title_parts.append(f"Team: {team}")
|
|
if week:
|
|
title_parts.append(f"Week {week}")
|
|
elif week_start and week_end:
|
|
title_parts.append(f"Weeks {week_start}-{week_end}")
|
|
if player:
|
|
title_parts.append(f"Player: {player}")
|
|
|
|
title = " | ".join(title_parts)
|
|
|
|
output_table(title, ["Week", "Player", "From", "To", "Move ID"], rows)
|
|
|
|
except Exception as e:
|
|
handle_error(e)
|
|
|
|
|
|
@transactions_app.command("list")
|
|
def transactions_list(
|
|
team: Annotated[
|
|
Optional[str], typer.Option("--team", "-t", help="Filter by team abbreviation")
|
|
] = None,
|
|
week: Annotated[
|
|
Optional[int], typer.Option("--week", "-w", help="Filter by specific week")
|
|
] = None,
|
|
week_start: Annotated[
|
|
Optional[int], typer.Option("--week-start", help="Start week for range")
|
|
] = None,
|
|
week_end: Annotated[
|
|
Optional[int], typer.Option("--week-end", help="End week for range")
|
|
] = None,
|
|
player: Annotated[
|
|
Optional[str], typer.Option("--player", "-p", help="Filter by player name")
|
|
] = None,
|
|
season: Annotated[
|
|
Optional[int], typer.Option("--season", "-s", help="Season number")
|
|
] = None,
|
|
limit: Annotated[
|
|
int, typer.Option("--limit", "-n", help="Max results to display")
|
|
] = 50,
|
|
include_cancelled: Annotated[
|
|
bool, typer.Option("--include-cancelled", help="Include cancelled transactions")
|
|
] = False,
|
|
):
|
|
"""
|
|
List transactions with optional filters (explicit command).
|
|
|
|
This is the same as calling 'majordomo transactions' without a subcommand.
|
|
"""
|
|
try:
|
|
season = get_season(season)
|
|
|
|
# Handle week vs week_start/week_end
|
|
if week is not None:
|
|
week_start = week
|
|
week_end = week
|
|
|
|
# Build filter parameters
|
|
team_list = [team] if team else None
|
|
player_list = [player] if player else None
|
|
|
|
# Get transactions from API
|
|
transactions = state.api.get_transactions(
|
|
season=season,
|
|
team_abbrev=team_list,
|
|
week_start=week_start,
|
|
week_end=week_end,
|
|
player_name=player_list,
|
|
cancelled=True if include_cancelled else False,
|
|
)
|
|
|
|
# Filter out cancelled transactions unless explicitly requested
|
|
if not include_cancelled:
|
|
transactions = [t for t in transactions if not t.get("cancelled", False)]
|
|
|
|
# Limit results
|
|
transactions = transactions[:limit]
|
|
|
|
if state.json_output:
|
|
output_json(transactions)
|
|
return
|
|
|
|
if not transactions:
|
|
console.print(
|
|
f"[yellow]No transactions found for the given filters (Season {season})[/yellow]"
|
|
)
|
|
return
|
|
|
|
# Build table rows
|
|
rows = []
|
|
for t in transactions:
|
|
player_dict = t.get("player", {})
|
|
oldteam_dict = t.get("oldteam", {})
|
|
newteam_dict = t.get("newteam", {})
|
|
|
|
player_name = (
|
|
player_dict.get("name", "N/A")
|
|
if isinstance(player_dict, dict)
|
|
else "N/A"
|
|
)
|
|
old_abbrev = (
|
|
oldteam_dict.get("abbrev", "N/A")
|
|
if isinstance(oldteam_dict, dict)
|
|
else "N/A"
|
|
)
|
|
new_abbrev = (
|
|
newteam_dict.get("abbrev", "N/A")
|
|
if isinstance(newteam_dict, dict)
|
|
else "N/A"
|
|
)
|
|
|
|
rows.append(
|
|
[
|
|
t.get("week", ""),
|
|
player_name,
|
|
old_abbrev,
|
|
new_abbrev,
|
|
t.get("moveid", ""),
|
|
]
|
|
)
|
|
|
|
# Build title with filters
|
|
title_parts = [f"Transactions (Season {season})"]
|
|
if team:
|
|
title_parts.append(f"Team: {team}")
|
|
if week:
|
|
title_parts.append(f"Week {week}")
|
|
elif week_start and week_end:
|
|
title_parts.append(f"Weeks {week_start}-{week_end}")
|
|
if player:
|
|
title_parts.append(f"Player: {player}")
|
|
|
|
title = " | ".join(title_parts)
|
|
|
|
output_table(title, ["Week", "Player", "From", "To", "Move ID"], rows)
|
|
|
|
except Exception as e:
|
|
handle_error(e)
|
|
|
|
|
|
@transactions_app.command("simulate")
|
|
def transactions_simulate(
|
|
team: Annotated[str, typer.Argument(help="Team abbreviation (e.g., CLS)")],
|
|
moves: Annotated[
|
|
str,
|
|
typer.Argument(
|
|
help="Moves as 'Player:Target,Player:Target' (e.g., 'Stott:CLSMiL,Walker:CLS')"
|
|
),
|
|
],
|
|
season: Annotated[
|
|
Optional[int], typer.Option("--season", "-s", help="Season number")
|
|
] = None,
|
|
):
|
|
"""
|
|
Simulate transactions and check compliance without making changes.
|
|
|
|
Validates that after all proposed moves:
|
|
- Active (ML) roster has exactly 26 players
|
|
- Total ML sWAR does not exceed team salary cap
|
|
|
|
Examples:
|
|
transactions simulate CLS "Stott:CLSMiL,Walker:CLS,Castellanos:FA,Martin:CLS"
|
|
transactions simulate DEN "Player1:DENMiL,Player2:DEN"
|
|
"""
|
|
try:
|
|
season = get_season(season)
|
|
team_upper = team.upper()
|
|
|
|
# Parse moves
|
|
parsed_moves: List[tuple] = []
|
|
for move_str in moves.split(","):
|
|
move_str = move_str.strip()
|
|
if not move_str:
|
|
continue
|
|
parts = move_str.split(":")
|
|
if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
|
|
console.print(f"[red]Invalid move format:[/red] '{move_str}'")
|
|
console.print("Expected format: 'Player Name:TARGET'")
|
|
raise typer.Exit(1)
|
|
parsed_moves.append((parts[0].strip(), parts[1].strip().upper()))
|
|
|
|
if not parsed_moves:
|
|
console.print("[red]No moves provided[/red]")
|
|
raise typer.Exit(1)
|
|
|
|
# Get team info (includes salary_cap)
|
|
team_data = state.api.get_team(abbrev=team_upper, season=season)
|
|
salary_cap = team_data.get("salary_cap")
|
|
|
|
# Get current roster
|
|
roster = state.api.get_team_roster(team_id=team_data["id"], which="current")
|
|
active_players = roster.get("active", {}).get("players", [])
|
|
|
|
# Build current ML state: {player_name: wara}
|
|
ml_roster = {}
|
|
for p in active_players:
|
|
ml_roster[p["name"]] = float(p.get("wara", 0) or 0)
|
|
|
|
current_count = len(ml_roster)
|
|
current_swar = sum(ml_roster.values())
|
|
|
|
# Simulate each move
|
|
move_details = []
|
|
errors = []
|
|
|
|
for player_name, target in parsed_moves:
|
|
# Look up the player
|
|
player = state.api.get_player(name=player_name, season=season)
|
|
if not player:
|
|
search = state.api.search_players(
|
|
query=player_name, season=season, limit=1
|
|
)
|
|
if search:
|
|
player = search[0]
|
|
else:
|
|
errors.append(f"Player '{player_name}' not found")
|
|
continue
|
|
|
|
p_name = player["name"]
|
|
p_wara = float(player.get("wara", 0) or 0)
|
|
p_team = (
|
|
player.get("team", {}).get("abbrev", "?")
|
|
if isinstance(player.get("team"), dict)
|
|
else "?"
|
|
)
|
|
|
|
# Determine if this move adds to or removes from ML
|
|
target_is_ml = target == team_upper
|
|
currently_on_ml = p_name in ml_roster
|
|
|
|
impact = ""
|
|
|
|
if currently_on_ml and not target_is_ml:
|
|
# Removing from ML (demote, release, trade away)
|
|
del ml_roster[p_name]
|
|
impact = f"[red]-{p_wara:.2f}[/red]"
|
|
elif not currently_on_ml and target_is_ml:
|
|
# Adding to ML (promote, sign, trade in)
|
|
ml_roster[p_name] = p_wara
|
|
impact = f"[green]+{p_wara:.2f}[/green]"
|
|
elif currently_on_ml and target_is_ml:
|
|
impact = "[dim]no change (already ML)[/dim]"
|
|
else:
|
|
impact = "[dim]no ML impact[/dim]"
|
|
|
|
move_details.append(
|
|
(p_name, f"{p_team} → {target}", f"{p_wara:.2f}", impact)
|
|
)
|
|
|
|
# Calculate post-transaction state
|
|
post_count = len(ml_roster)
|
|
post_swar = sum(ml_roster.values())
|
|
|
|
# Output
|
|
if state.json_output:
|
|
output_json(
|
|
{
|
|
"team": team_upper,
|
|
"salary_cap": salary_cap,
|
|
"before": {
|
|
"ml_count": current_count,
|
|
"ml_swar": round(current_swar, 2),
|
|
},
|
|
"after": {
|
|
"ml_count": post_count,
|
|
"ml_swar": round(post_swar, 2),
|
|
},
|
|
"moves": [
|
|
{"player": m[0], "move": m[1], "wara": m[2]}
|
|
for m in move_details
|
|
],
|
|
"errors": errors,
|
|
"roster_ok": post_count == 26,
|
|
"cap_ok": post_swar <= salary_cap if salary_cap else True,
|
|
}
|
|
)
|
|
return
|
|
|
|
# Print errors if any
|
|
for err in errors:
|
|
console.print(f"[red]Error:[/red] {err}")
|
|
|
|
# Moves table
|
|
output_table(
|
|
f"Simulated Moves for {team_upper}",
|
|
["Player", "Move", "WARA", "ML Impact"],
|
|
move_details,
|
|
)
|
|
|
|
console.print()
|
|
|
|
# Compliance summary
|
|
roster_ok = post_count == 26
|
|
cap_ok = post_swar <= salary_cap if salary_cap else True
|
|
cap_space = salary_cap - post_swar if salary_cap else 0
|
|
|
|
roster_status = "[green]PASS[/green]" if roster_ok else "[red]FAIL[/red]"
|
|
cap_status = "[green]PASS[/green]" if cap_ok else "[red]FAIL[/red]"
|
|
|
|
panel = Panel(
|
|
f"[bold]ML Roster:[/bold] {current_count} → {post_count} {roster_status}\n"
|
|
f"[bold]ML sWAR:[/bold] {current_swar:.2f} → {post_swar:.2f}\n"
|
|
f"[bold]Salary Cap:[/bold] {salary_cap:.1f}\n"
|
|
f"[bold]Cap Space:[/bold] {cap_space:.2f} {cap_status}",
|
|
title="Compliance Check",
|
|
border_style="green" if (roster_ok and cap_ok) else "red",
|
|
)
|
|
console.print(panel)
|
|
|
|
if not roster_ok:
|
|
console.print(
|
|
f"\n[red]Roster must be exactly 26. Currently {post_count}.[/red]"
|
|
)
|
|
if not cap_ok:
|
|
console.print(
|
|
f"\n[red]Over salary cap by {post_swar - salary_cap:.2f} sWAR.[/red]"
|
|
)
|
|
|
|
except typer.Exit:
|
|
raise
|
|
except Exception as e:
|
|
handle_error(e)
|