Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion python/valuecell/agents/strategy_agent/decision/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
TradeSide,
UserRequest,
)
from ..utils import extract_price_map
from ..utils import extract_price_map, send_discord_message
from .interfaces import Composer
from .system_prompt import SYSTEM_PROMPT

Expand Down Expand Up @@ -71,6 +71,12 @@ async def compose(self, context: ComposeContext) -> List[TradeInstruction]:
logger.exception("LLM invocation failed")
return []

# Optionally forward non-NOOP plan rationale to Discord webhook (env-driven)
try:
await self._send_plan_to_discord(plan)
except Exception as exc: # do not fail compose on notification errors
logger.error("Failed sending plan to Discord: {}", exc)

return self._normalize_plan(context, plan)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -258,6 +264,54 @@ async def _call_llm(self, prompt: str) -> LlmPlanProposal:
logger.debug("Received LLM response {}", content)
return content

async def _send_plan_to_discord(self, plan: LlmPlanProposal) -> None:
"""Send plan rationale to Discord when there are actionable items.

Behavior:
- If `plan.items` contains any item whose `action` is not `NOOP`, send
a Markdown-formatted message containing the plan-level rationale and
per-item brief rationales.
- Reads webhook from `STRATEGY_AGENT_DISCORD_WEBHOOK_URL` (handled by
`send_discord_message`). Does nothing if no actionable items exist.
"""
actionable = [it for it in plan.items if it.action != LlmDecisionAction.NOOP]
if not actionable:
return

strategy_name = self._request.trading_config.strategy_name
parts = [f"## Strategy {strategy_name} — Actions Detected\n"]
# top-level rationale
top_r = plan.rationale
if top_r:
parts.append("**Overall rationale:**\n")
parts.append(f"{top_r}\n")

parts.append("**Items:**\n")
for it in actionable:
action = it.action.value
instr_parts = []
# instrument symbol if exists
instr_parts.append(f"`{it.instrument.symbol}`")
# target qty / magnitude
instr_parts.append(f"qty={it.target_qty}")
# item rationale
item_r = it.rationale
summary = " — ".join(instr_parts) if instr_parts else ""
if item_r:
parts.append(f"- **{action}** {summary} — Reasoning: {item_r}\n")
else:
parts.append(f"- **{action}** {summary}\n")

message = "\n".join(parts)

try:
resp = await send_discord_message(message)
logger.debug(
"Sent plan to Discord, response len={}", len(resp) if resp else 0
)
except Exception as exc:
logger.warning("Error sending plan to Discord, err={}", exc)

# ------------------------------------------------------------------
# Normalization / guardrails helpers

Expand Down
51 changes: 50 additions & 1 deletion python/valuecell/agents/strategy_agent/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Dict
import os
from typing import Dict, Optional

import ccxt.pro as ccxtpro
import httpx
from loguru import logger


Expand Down Expand Up @@ -69,3 +71,50 @@ def get_exchange_cls(exchange_id: str):
if exchange_cls is None:
raise RuntimeError(f"Exchange '{exchange_id}' not found in ccxt.pro")
return exchange_cls


async def send_discord_message(
content: str,
webhook_url: Optional[str] = None,
*,
raise_for_status: bool = True,
timeout: float = 10.0,
) -> str:
"""Send a message to Discord via webhook asynchronously.

Reads the webhook URL from the environment variable
`STRATEGY_AGENT_DISCORD_WEBHOOK_URL` when `webhook_url` is not provided.

Args:
content: The message content to send.
webhook_url: Optional webhook URL to override the environment variable.
raise_for_status: If True, raise on non-2xx responses.
timeout: Request timeout in seconds.

Returns:
The response body as text.

Raises:
ValueError: If no webhook URL is provided or available in env.
ImportError: If `httpx` is not installed.
httpx.HTTPStatusError: If `raise_for_status` is True and the response is an HTTP error.
"""
if webhook_url is None:
webhook_url = os.getenv("STRATEGY_AGENT_DISCORD_WEBHOOK_URL")

if not webhook_url:
raise ValueError(
"Discord webhook URL not provided and STRATEGY_AGENT_DISCORD_WEBHOOK_URL is not set"
)

headers = {
"Accept": "text",
"Content-Type": "application/json",
}
payload = {"content": content}

async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(webhook_url, headers=headers, json=payload)
if raise_for_status:
resp.raise_for_status()
return resp.text