#!/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, safe_nested, ) 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 — include affiliate rosters (IL, MiL) if team: base = team.upper().removesuffix("MIL").removesuffix("IL") team_list = [base, f"{base}IL", f"{base}MiL"] else: team_list = 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_name = safe_nested(t, "player", "name") old_abbrev = safe_nested(t, "oldteam", "abbrev") new_abbrev = safe_nested(t, "newteam", "abbrev") 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 — include affiliate rosters (IL, MiL) if team: base = team.upper().removesuffix("MIL").removesuffix("IL") team_list = [base, f"{base}IL", f"{base}MiL"] else: team_list = 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_name = safe_nested(t, "player", "name") old_abbrev = safe_nested(t, "oldteam", "abbrev") new_abbrev = safe_nested(t, "newteam", "abbrev") 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 = safe_nested(player, "team", "abbrev", default="?") # 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)