Skip to content

agents

athena.agents

run_investing_agent(portfolio_file_name, project_code, events_section_title, events_description, data_source_description, data_summary_description, generate_summary=False, system_prompt_file='system_prompt.j2', pricing_manager=None, use_commodities=False, short_ok=False)

Run the investing agent with the given configuration.

Loads the portfolio, builds a system prompt, generates an initial message, and enters a conversation loop with the LLM that can execute QUOTE, BUY, and SELL commands. Saves the portfolio after the loop completes.

Parameters:

Name Type Description Default
portfolio_file_name str

Path to the portfolio Excel file.

required
project_code str

The project code for fetching events.

required
events_section_title str

Title for the events section.

required
events_description str

Description of the events data.

required
data_source_description str

Description of the data source.

required
data_summary_description str

Description for the data summary.

required
generate_summary bool

Whether to generate an email summary at the end.

False
system_prompt_file str

Jinja2 template filename for the system prompt.

'system_prompt.j2'
pricing_manager

Optional pricing manager passed to portfolio loading.

None
use_commodities bool

If True, use commodity futures quote functions instead of stock quote functions.

False
short_ok bool

If True, allow short selling and use corresponding prompt templates.

False

Returns:

Type Description

The email summary string if generate_summary is True,

otherwise an empty string.

Source code in src/athena/agents/agents.py
def run_investing_agent(
    portfolio_file_name: str,
    project_code: str,
    events_section_title: str,
    events_description: str,
    data_source_description: str,
    data_summary_description: str,
    generate_summary: bool = False,
    system_prompt_file:str = "system_prompt.j2",
    pricing_manager = None,
    use_commodities: bool = False,
    short_ok: bool = False
):
    """Run the investing agent with the given configuration.

    Loads the portfolio, builds a system prompt, generates an initial message,
    and enters a conversation loop with the LLM that can execute QUOTE, BUY,
    and SELL commands. Saves the portfolio after the loop completes.

    Args:
        portfolio_file_name: Path to the portfolio Excel file.
        project_code: The project code for fetching events.
        events_section_title: Title for the events section.
        events_description: Description of the events data.
        data_source_description: Description of the data source.
        data_summary_description: Description for the data summary.
        generate_summary: Whether to generate an email summary at the end.
        system_prompt_file: Jinja2 template filename for the system prompt.
        pricing_manager: Optional pricing manager passed to portfolio loading.
        use_commodities: If True, use commodity futures quote functions
            instead of stock quote functions.
        short_ok: If True, allow short selling and use corresponding
            prompt templates.

    Returns:
        The email summary string if ``generate_summary`` is True,
        otherwise an empty string.
    """
    portfolio = load_portfolio_from_excel(
        portfolio_file_name,
        primary_currency=Currency.USD,
        create_if_missing=True,
        error_out_negative_cash=False,
        error_out_negative_quantity=True,
        pricing_manager=pricing_manager,
    )

    # Override system prompt if short_ok is True and the default system prompt is being used.
    if short_ok and system_prompt_file == "system_prompt.j2":
        system_prompt_file = "system_prompt_short_ok.j2"

    system_prompt = get_system_prompt(data_source_description, data_summary_description, system_prompt_file=system_prompt_file)
    initial_message = generate_initial_message(
        portfolio, project_code, events_section_title, events_description, use_commodities=use_commodities
    )

    messages_array = [
                        {
                            "role": "user",
                            "content": initial_message
                        }
                    ]

    func_get_quote = commodities_get_quote if use_commodities else get_quote
    func_get_quote_for_context = commodities_get_quote_for_context if use_commodities else get_quote_for_context

    while True:

        response = stream_llm_response(
            system_prompt=system_prompt,
            messages=messages_array,
            model=DEFAULT_ANTHROPIC_MODEL
        )

        messages_array.append(
            {
                "role": "assistant",
                "content": response
            }
        )

        # print("Response from Anthropic:")
        # print(response)

        quotes_request = False
        new_content = ""

        if response.find("|--COMMAND--|") != -1:
            command_section = response.split("|--COMMAND--|")[1]
            command_section = command_section.strip()
            lines = command_section.split("\n")
            # print(lines)
            for line in lines:
                line = line.strip()
                if line.startswith("QUOTE:"):
                    quotes_request = True
                    line = line.replace("QUOTE:", "").strip()
                    symbols = line.split(",")
                    for symbol in symbols:
                        symbol = symbol.strip()
                        print(f"Fetching quote for symbol: {symbol}")
                        quote_for_context = func_get_quote_for_context(symbol)
                        new_content += f"\n{quote_for_context}\n"
                elif line.startswith("BUY:"):
                    line = line.replace("BUY:", "").strip()
                    parts = line.split(",")
                    for part in parts:
                        toks = part.strip().split("|")
                        if len(toks) == 2:
                            symbol = toks[0].strip()
                            quantity = toks[1].strip()
                            ask_price, bid_price = func_get_quote(symbol)
                            if ask_price == -1.0 and bid_price == -1.0:
                                print(f"Error fetching quote for {symbol}. Skipping BUY order.\n")
                                continue
                            print(f"Placing BUY order for {quantity} shares of {symbol}\n")
                            transaction = portfolio.add_transaction_now(
                                symbol=symbol,
                                transaction_type=TransactionType.BUY,
                                quantity=Decimal(quantity),
                                price=ask_price  # type: ignore[arg-type]
                            )
                        else:
                            print(f"Invalid BUY command format: {part}\n")
                elif line.startswith("SELL:"):
                    line = line.replace("SELL:", "").strip()
                    parts = line.split(",")
                    for part in parts:
                        toks = part.strip().split("|")
                        if len(toks) == 2:
                            symbol = toks[0].strip()
                            quantity = toks[1].strip()
                            # Check if shorting is allowed
                            if not short_ok:
                                current_positions = get_positions(datetime.now(timezone.utc), portfolio)
                                position_qty = Decimal("0")
                                for pos in current_positions:
                                    if pos.symbol == symbol:
                                        position_qty = pos.quantity
                                        break
                                if position_qty < Decimal(quantity):
                                    print(f"Invalid SELL command: shorting is not allowed for {symbol}\n")
                                    continue
                            ask_price, bid_price = func_get_quote(symbol)
                            if ask_price == -1.0 and bid_price == -1.0:
                                print(f"Error fetching quote for {symbol}. Skipping SELL order.\n")
                                continue
                            print(f"Placing SELL order for {quantity} shares of {symbol}\n")
                            transaction = portfolio.add_transaction_now(
                                symbol=symbol,
                                transaction_type=TransactionType.SELL,
                                quantity=Decimal(quantity),
                                price=bid_price  # type: ignore[arg-type]
                            )
                        else:
                            print(f"Invalid SELL command format: {part}\n")
                else:
                    print(f"Unknown command: {line}\n")

        if quotes_request:
            print("\n\n\nSENDING MESSAGE TO ANTHROPIC:\n")
            print(new_content)
            messages_array.append(
                {
                    "role": "user",
                    "content": new_content.strip()
                }
            )
        else:
            break 

    save_portfolio_to_excel(portfolio, portfolio_file_name)

    if generate_summary:
        email_instructions = get_email_summary_instructions()
        messages_array.append({
            "role": "user",
            "content": email_instructions
        })
        summary_response = stream_llm_response(
            system_prompt=email_instructions,
            messages=messages_array,
            model=DEFAULT_ANTHROPIC_MODEL
        )
        messages_array.append(
            {
                "role": "assistant",
                "content": summary_response
            }
        )

        print(json.dumps(messages_array, indent=4))

        return summary_response

    return ""

get_quote(symbol)

Get the latest ask/bid quote for a stock symbol via the Massive API.

Parameters:

Name Type Description Default
symbol str

Stock ticker symbol (e.g., "AAPL").

required

Returns:

Type Description
tuple[float, float]

A tuple of (ask_price, bid_price). Returns (-1.0, -1.0) on error.

Source code in src/athena/agents/agents.py
def get_quote(symbol: str) -> tuple[float, float]:
    """Get the latest ask/bid quote for a stock symbol via the Massive API.

    Args:
        symbol: Stock ticker symbol (e.g., "AAPL").

    Returns:
        A tuple of (ask_price, bid_price). Returns (-1.0, -1.0) on error.
    """
    try:
        client = RESTClient(MASSIVE_API_KEY)
        quote = client.get_last_quote(symbol)
        return quote.ask_price, quote.bid_price # type: ignore
    except Exception as e:
        print(f"Error fetching quote for {symbol}: {e}")
        return -1.0, -1.0

get_quote_for_context(symbol)

Get comprehensive quote information for a stock symbol for LLM context.

Includes bid/ask, current day OHLC (if market open), and price movements.

Parameters:

Name Type Description Default
symbol str

Stock ticker symbol (e.g., "AAPL").

required

Returns:

Type Description
str

A newline-joined string with stock info, bid/ask prices, market

str

status, OHLC data, and 1-week/1-month/1-year price changes.

Source code in src/athena/agents/agents.py
def get_quote_for_context(symbol: str) -> str:
    """Get comprehensive quote information for a stock symbol for LLM context.

    Includes bid/ask, current day OHLC (if market open), and price movements.

    Args:
        symbol: Stock ticker symbol (e.g., "AAPL").

    Returns:
        A newline-joined string with stock info, bid/ask prices, market
        status, OHLC data, and 1-week/1-month/1-year price changes.
    """
    client = RESTClient(MASSIVE_API_KEY)
    result_parts = []
    result_parts.append(f"Stock: {symbol}")

    # 1. Get current bid/ask
    try:
        quote = client.get_last_quote(symbol)
        bid_price = quote.bid_price
        ask_price = quote.ask_price
        result_parts.append(f"Current Bid: ${bid_price:.2f}, Ask: ${ask_price:.2f}, Spread: ${(ask_price - bid_price):.2f}")
    except Exception as e:
        result_parts.append(f"Bid/Ask: Unable to fetch ({e})")
        return "\n".join(result_parts)

    # 2. Check if market is currently open and get today's OHLC
    et_tz = ZoneInfo('America/New_York')
    now_et = datetime.now(et_tz)
    market_open_time = now_et.replace(hour=9, minute=30, second=0, microsecond=0)
    market_close_time = now_et.replace(hour=16, minute=0, second=0, microsecond=0)
    is_weekday = now_et.weekday() < 5  # Monday = 0, Friday = 4
    is_market_hours = market_open_time <= now_et <= market_close_time
    market_is_open = is_weekday and is_market_hours

    today_str = now_et.strftime("%Y-%m-%d")
    current_price = None

    if market_is_open:
        try:
            aggs = list(client.list_aggs(
                ticker=symbol,
                multiplier=1,
                timespan="day",
                from_=today_str,
                to=today_str,
                limit=1
            ))
            if aggs:
                today_agg = aggs[0]
                current_price = today_agg.close
                result_parts.append(f"Today (Market Open): Price: ${today_agg.close:.2f}, Open: ${today_agg.open:.2f}, High: ${today_agg.high:.2f}, Low: ${today_agg.low:.2f}")
        except Exception as e:
            result_parts.append(f"Today's OHLC: Unable to fetch ({e})")
    else:
        result_parts.append("Market Status: Closed")

    # 3-5. Get price movements for 1 week, 1 month, 1 year
    now_utc = datetime.now(timezone.utc)
    one_week_ago = (now_utc - timedelta(days=10)).strftime("%Y-%m-%d")  # Extra days to account for weekends
    one_month_ago = (now_utc - timedelta(days=35)).strftime("%Y-%m-%d")
    one_year_ago = (now_utc - timedelta(days=370)).strftime("%Y-%m-%d")

    try:
        # Get recent data to find current price if we don't have it
        recent_aggs = list(client.list_aggs(
            ticker=symbol,
            multiplier=1,
            timespan="day",
            from_=one_week_ago,
            to=today_str,
            limit=50
        ))

        if recent_aggs:
            if current_price is None:
                current_price = recent_aggs[-1].close
                result_parts.append(f"Last Close: ${current_price:.2f}")

            # Week ago: find price from ~5-7 trading days ago
            week_ago_price = recent_aggs[0].close if len(recent_aggs) >= 5 else None

            # Get month ago price
            month_aggs = list(client.list_aggs(
                ticker=symbol,
                multiplier=1,
                timespan="day",
                from_=one_month_ago,
                to=(now_utc - timedelta(days=25)).strftime("%Y-%m-%d"),
                limit=5
            ))
            month_ago_price = month_aggs[-1].close if month_aggs else None

            # Get year ago price
            year_aggs = list(client.list_aggs(
                ticker=symbol,
                multiplier=1,
                timespan="day",
                from_=one_year_ago,
                to=(now_utc - timedelta(days=360)).strftime("%Y-%m-%d"),
                limit=5
            ))
            year_ago_price = year_aggs[-1].close if year_aggs else None

            # Build price movement summary
            movements = []
            if week_ago_price and current_price:
                week_change = current_price - week_ago_price
                week_pct = (week_change / week_ago_price) * 100
                movements.append(f"1-Week: {week_change:+.2f} ({week_pct:+.1f}%)")

            if month_ago_price and current_price:
                month_change = current_price - month_ago_price
                month_pct = (month_change / month_ago_price) * 100
                movements.append(f"1-Month: {month_change:+.2f} ({month_pct:+.1f}%)")

            if year_ago_price and current_price:
                year_change = current_price - year_ago_price
                year_pct = (year_change / year_ago_price) * 100
                movements.append(f"1-Year: {year_change:+.2f} ({year_pct:+.1f}%)")

            if movements:
                result_parts.append("Price Changes: " + ", ".join(movements))
    except Exception as e:
        result_parts.append(f"Price Movements: Unable to fetch ({e})")

    return "\n".join(result_parts)

stream_llm_response(system_prompt, messages, model)

Send messages to an Anthropic LLM and return the response text.

Retries up to 3 times with exponential backoff on failure.

Parameters:

Name Type Description Default
system_prompt str

The system prompt to prepend to the conversation.

required
messages list[dict[str, str]]

List of message dicts with "role" and "content" keys.

required
model str

Anthropic model name (e.g., "claude-sonnet-4-5").

required

Returns:

Type Description
str

The text content of the model's response.

Raises:

Type Description
Exception

Re-raises the last exception after all retry attempts are exhausted.

Source code in src/athena/agents/agents.py
def stream_llm_response(system_prompt: str, messages: list[dict[str, str]], model: str) -> str:
    """Send messages to an Anthropic LLM and return the response text.

    Retries up to 3 times with exponential backoff on failure.

    Args:
        system_prompt: The system prompt to prepend to the conversation.
        messages: List of message dicts with "role" and "content" keys.
        model: Anthropic model name (e.g., "claude-sonnet-4-5").

    Returns:
        The text content of the model's response.

    Raises:
        Exception: Re-raises the last exception after all retry attempts
            are exhausted.
    """
    max_num_attempts = 3
    num_attempts = 0

    # Format model name for litellm (prefix with anthropic/)
    litellm_model = f"anthropic/{model}"
    litellm_messages = [{"role": "system", "content": system_prompt}] + messages

    while num_attempts < max_num_attempts:
        try:
            response = litellm.completion(
                model=litellm_model,
                messages=litellm_messages,
                max_tokens=8192,
            )
            return response.choices[0].message.content  # type: ignore

        except Exception as e:
            print(f"Attempt {num_attempts} failed: {e}")
            print(f"Retrying after {SLEEP_TIMES_FOR_ANTHROPIC[num_attempts]} seconds...")
            time.sleep(SLEEP_TIMES_FOR_ANTHROPIC[num_attempts])
            num_attempts += 1

            if num_attempts >= max_num_attempts:
                raise e

    return ""  # Should never reach here

generate_initial_message(portfolio, project_code, events_section_title, events_description, use_commodities=False, short_ok=False)

Generate the initial user message from a Jinja2 template.

Fetches events data, calculates portfolio values, and optionally includes available commodity contracts to compose the first message sent to the LLM.

Parameters:

Name Type Description Default
portfolio Portfolio

The portfolio to summarize in the message.

required
project_code str

The Emerging Trajectories project code for events.

required
events_section_title str

Title for the events section in the message.

required
events_description str

Description of the events data source.

required
use_commodities bool

If True, include available futures contract info.

False
short_ok bool

If True, use the short-selling-enabled message template.

False

Returns:

Type Description
str

The rendered initial message string ready to send to the LLM.

Source code in src/athena/agents/agents.py
def generate_initial_message(
    portfolio: Portfolio,
    project_code: str,
    events_section_title: str,
    events_description: str,
    use_commodities: bool = False,
    short_ok: bool = False
) -> str:
    """Generate the initial user message from a Jinja2 template.

    Fetches events data, calculates portfolio values, and optionally includes
    available commodity contracts to compose the first message sent to the LLM.

    Args:
        portfolio: The portfolio to summarize in the message.
        project_code: The Emerging Trajectories project code for events.
        events_section_title: Title for the events section in the message.
        events_description: Description of the events data source.
        use_commodities: If True, include available futures contract info.
        short_ok: If True, use the short-selling-enabled message template.

    Returns:
        The rendered initial message string ready to send to the LLM.
    """
    current_time = datetime.now(timezone.utc)

    # Fetch events data
    etep = EmergingTrajectoriesEventsProxy(ET_API_KEY)
    events_data = etep.get_events(project_code)

    # Calculate portfolio data
    total_market_value = calculate_portfolio_value_on_date(portfolio, current_time, Currency.USD)

    # Generate available contracts section for commodities
    available_contracts_section = ""
    if use_commodities:
        front_months = commodities_get_front_month_symbols()
        available_contracts_section = """|--AVAILABLE CONTRACTS--|

You can trade the following futures contracts:
- Natural Gas (NG): NGF26, NGG26, NGH26, etc. (10,000 MMBtu per contract) - uses 2-digit year
- WTI Crude (CL): CLF6, CLG6, CLH6, etc. (1,000 barrels per contract) - uses 1-digit year
- RBOB Gasoline (RB): RBF6, RBG6, RBH6, etc. (42,000 gallons per contract) - uses 1-digit year

Symbol format: [ROOT][MONTH][YEAR]
Month codes: F=Jan, G=Feb, H=Mar, J=Apr, K=May, M=Jun, N=Jul, Q=Aug, U=Sep, V=Oct, X=Nov, Z=Dec

Suggested liquid contracts (front months): """ + ", ".join(front_months) + "\n"

    # Choose the right inital message.
    if short_ok:
        initial_message_file = "initial_message_short_ok.j2"
    else:
        initial_message_file = "initial_message.j2"

    # Render template
    template = jinja_env.get_template(initial_message_file)
    return template.render(
        current_datetime=current_time.isoformat(),
        events_section_title=events_section_title,
        events_description=events_description,
        events_data=events_data,
        positions_table=format_positions_table(portfolio, current_time),
        cash_balances=format_cash_balances(portfolio, current_time),
        total_market_value=total_market_value,
        transaction_log=format_transaction_log(portfolio),
        available_contracts_section=available_contracts_section,
    )

get_system_prompt(data_source_description, data_summary_description, system_prompt_file='system_prompt.j2')

Generate system prompt from a Jinja2 template.

Parameters:

Name Type Description Default
data_source_description str

Description of the data source to inject into the prompt.

required
data_summary_description str

Description of the data summary to inject into the prompt.

required
system_prompt_file str

Jinja2 template filename for the system prompt.

'system_prompt.j2'

Returns:

Type Description
str

The rendered system prompt string.

Source code in src/athena/agents/agents.py
def get_system_prompt(data_source_description: str, data_summary_description: str, system_prompt_file:str = "system_prompt.j2",) -> str:
    """Generate system prompt from a Jinja2 template.

    Args:
        data_source_description: Description of the data source to inject
            into the prompt.
        data_summary_description: Description of the data summary to inject
            into the prompt.
        system_prompt_file: Jinja2 template filename for the system prompt.

    Returns:
        The rendered system prompt string.
    """
    template = jinja_env.get_template(system_prompt_file)
    return template.render(
        data_source_description=data_source_description,
        data_summary_description=data_summary_description,
    )

get_email_summary_instructions()

Load email summary instructions from a Jinja2 template.

Returns:

Type Description
str

The rendered email summary instructions string.

Source code in src/athena/agents/agents.py
def get_email_summary_instructions() -> str:
    """Load email summary instructions from a Jinja2 template.

    Returns:
        The rendered email summary instructions string.
    """
    template = jinja_env.get_template("email_summary.j2")
    return template.render()