|
| 1 | +import os |
| 2 | +from enum import Enum |
| 3 | + |
| 4 | +from models import ( |
| 5 | + AssetSignalRequest, |
| 6 | + AssetSignalResponse, |
| 7 | + FinancialNewsSentimentRequest, |
| 8 | + FinancialNewsSentimentResponse, |
| 9 | + FinancialSentimentRequest, |
| 10 | + FinancialSentimentResponse, |
| 11 | + NewsSentiment, |
| 12 | + StockPriceRequest, |
| 13 | + StockPriceResponse, |
| 14 | + TickerRequest, |
| 15 | + TickerResponse, |
| 16 | +) |
| 17 | +from uagents import Agent, Context, Model |
| 18 | +from uagents.experimental.chat_agent import ChatAgent |
| 19 | +from uagents.experimental.quota import QuotaProtocol, RateLimit |
| 20 | +from uagents_core.models import ErrorMessage |
| 21 | + |
| 22 | +COMPANY_TICKER_RESOLVER_AGENT = os.getenv("company-ticker-resolver-agent") |
| 23 | +FINANCIAL_NEWS_SENTIMENT_AGENT = os.getenv("financial-news-sentiment-agent") |
| 24 | +FINBERT_FINANCIAL_SENTIMENT_AGENT = os.getenv("finbert-financial-sentiment-agent") |
| 25 | +STOCK_PRICE_AGENT = os.getenv("stock-price-agent") |
| 26 | + |
| 27 | +AGENT_SEED = os.getenv("AGENT_SEED", "<your-agent-seed>") |
| 28 | +AGENT_NAME = os.getenv("AGENT_NAME", "Asset Signal Agent") |
| 29 | + |
| 30 | + |
| 31 | +PORT = 8000 |
| 32 | +agent = ChatAgent( |
| 33 | + name=AGENT_NAME, |
| 34 | + seed=AGENT_SEED, |
| 35 | + port=PORT, |
| 36 | + endpoint=f"http://localhost:{PORT}/submit", |
| 37 | +) |
| 38 | + |
| 39 | + |
| 40 | +proto = QuotaProtocol( |
| 41 | + storage_reference=agent.storage, |
| 42 | + name="Company-Asset-Signal", |
| 43 | + version="0.1.0", |
| 44 | +) |
| 45 | + |
| 46 | +def generate_sentiment_overview(summary: list[NewsSentiment]) -> dict[str, int]: |
| 47 | + sentiments = [news.model_dump()["overall_sentiment_label"] for news in summary] |
| 48 | + count = len(sentiments) |
| 49 | + output = { |
| 50 | + "Bearish": 0, |
| 51 | + "Somewhat-Bearish": 0, |
| 52 | + "Neutral": 0, |
| 53 | + "Somewhat-Bullish": 0, |
| 54 | + "Bullish": 0, |
| 55 | + } |
| 56 | + for s in sentiments: |
| 57 | + output[s] += 1 |
| 58 | + for key in output: |
| 59 | + output[key] = output[key] / count |
| 60 | + converted_output = { |
| 61 | + "positive": output["Somewhat-Bullish"] / 2 + output["Bullish"], |
| 62 | + "neutral": output["Neutral"] |
| 63 | + + output["Somewhat-Bullish"] / 2 |
| 64 | + + output["Somewhat-Bearish"] / 2, |
| 65 | + "negative": output["Somewhat-Bearish"] / 2 + output["Bearish"], |
| 66 | + } |
| 67 | + return converted_output |
| 68 | + |
| 69 | + |
| 70 | +@proto.on_message( |
| 71 | + AssetSignalRequest, |
| 72 | + replies={AssetSignalResponse, ErrorMessage}, |
| 73 | + rate_limit=RateLimit(window_size_minutes=60, max_requests=3), |
| 74 | +) |
| 75 | +async def handle_request(ctx: Context, sender: str, msg: AssetSignalRequest): |
| 76 | + if not COMPANY_TICKER_RESOLVER_AGENT: |
| 77 | + ctx.logger.info("COMPANY_TICKER_RESOLVER_AGENT is not set") |
| 78 | + return |
| 79 | + if not FINANCIAL_NEWS_SENTIMENT_AGENT: |
| 80 | + ctx.logger.info("FINANCIAL_NEWS_SENTIMENT_AGENT is not set") |
| 81 | + return |
| 82 | + if not FINBERT_FINANCIAL_SENTIMENT_AGENT: |
| 83 | + ctx.logger.info("FINBERT_FINANCIAL_SENTIMENT_AGENT is not set") |
| 84 | + return |
| 85 | + if not STOCK_PRICE_AGENT: |
| 86 | + ctx.logger.info("STOCK_PRICE_AGENT is not set") |
| 87 | + return |
| 88 | + |
| 89 | + ticker_reply, ticker_status = await ctx.send_and_receive( |
| 90 | + COMPANY_TICKER_RESOLVER_AGENT, |
| 91 | + TickerRequest(company=msg.company_name), |
| 92 | + response_type=TickerResponse, |
| 93 | + ) |
| 94 | + if not isinstance(ticker_reply, TickerResponse): |
| 95 | + await ctx.send(sender, ErrorMessage(error=f"Ticker resolver failed: {ticker_status}")) |
| 96 | + return |
| 97 | + ticker = ticker_reply.ticker |
| 98 | + |
| 99 | + news_reply, news_status = await ctx.send_and_receive( |
| 100 | + FINANCIAL_NEWS_SENTIMENT_AGENT, |
| 101 | + FinancialNewsSentimentRequest(ticker=ticker), |
| 102 | + response_type=FinancialNewsSentimentResponse, |
| 103 | + ) |
| 104 | + if not isinstance(news_reply, FinancialNewsSentimentResponse): |
| 105 | + await ctx.send(sender, ErrorMessage(error=f"News sentiment failed: {news_status}")) |
| 106 | + return |
| 107 | + if not news_reply.summary: |
| 108 | + await ctx.send(sender, ErrorMessage(error="News sentiment returned empty summary")) |
| 109 | + return |
| 110 | + |
| 111 | + sentiment_summary = generate_sentiment_overview(news_reply.summary) |
| 112 | + |
| 113 | + finbert_reply, finbert_status = await ctx.send_and_receive( |
| 114 | + FINBERT_FINANCIAL_SENTIMENT_AGENT, |
| 115 | + FinancialSentimentRequest(text="\n".join([a.title for a in news_reply.summary[:10]])), |
| 116 | + response_type=FinancialSentimentResponse, |
| 117 | + ) |
| 118 | + if not isinstance(finbert_reply, FinancialSentimentResponse): |
| 119 | + await ctx.send(sender, ErrorMessage(error=f"FinBERT failed: {finbert_status}")) |
| 120 | + return |
| 121 | + |
| 122 | + s2 = finbert_reply.model_dump() |
| 123 | + combined = { |
| 124 | + "BUY": sentiment_summary["positive"] + s2["positive"], |
| 125 | + "WAIT": sentiment_summary["neutral"] + s2["neutral"], |
| 126 | + "SELL": sentiment_summary["negative"] + s2["negative"], |
| 127 | + } |
| 128 | + |
| 129 | + price_reply, price_status = await ctx.send_and_receive( |
| 130 | + STOCK_PRICE_AGENT, |
| 131 | + StockPriceRequest(ticker=ticker), |
| 132 | + response_type=StockPriceResponse, |
| 133 | + ) |
| 134 | + if not isinstance(price_reply, StockPriceResponse): |
| 135 | + await ctx.send(sender, ErrorMessage(error=f"Stock price failed: {price_status}")) |
| 136 | + return |
| 137 | + |
| 138 | + try: |
| 139 | + price = float(price_reply.text) |
| 140 | + except Exception: |
| 141 | + await ctx.send(sender, ErrorMessage(error=f"Invalid price payload: {getattr(price_reply, 'text', None)!r}")) |
| 142 | + return |
| 143 | + |
| 144 | + signal = max(combined, key=combined.get) |
| 145 | + |
| 146 | + sources = sorted({news.url for news in news_reply.summary if getattr(news, "url", None)}) |
| 147 | + |
| 148 | + await ctx.send( |
| 149 | + sender, |
| 150 | + AssetSignalResponse( |
| 151 | + signal=signal, |
| 152 | + price=price, |
| 153 | + sources=sources, |
| 154 | + ), |
| 155 | + ) |
| 156 | + |
| 157 | + |
| 158 | +agent.include(proto, publish_manifest=True) |
| 159 | + |
| 160 | + |
| 161 | +# Health Check code |
| 162 | +class HealthCheck(Model): |
| 163 | + pass |
| 164 | + |
| 165 | + |
| 166 | +class HealthStatus(str, Enum): |
| 167 | + HEALTHY = "healthy" |
| 168 | + UNHEALTHY = "unhealthy" |
| 169 | + |
| 170 | + |
| 171 | +class AgentHealth(Model): |
| 172 | + agent_name: str |
| 173 | + status: HealthStatus |
| 174 | + |
| 175 | + |
| 176 | +health_protocol = QuotaProtocol( |
| 177 | + storage_reference=agent.storage, name="HealthProtocol", version="0.1.0" |
| 178 | +) |
| 179 | + |
| 180 | + |
| 181 | +@health_protocol.on_message(HealthCheck, replies={AgentHealth}) |
| 182 | +async def handle_health_check(ctx: Context, sender: str, msg: HealthCheck): |
| 183 | + await ctx.send( |
| 184 | + sender, AgentHealth(agent_name=AGENT_NAME, status=HealthStatus.HEALTHY) |
| 185 | + ) |
| 186 | + |
| 187 | + |
| 188 | +agent.include(health_protocol, publish_manifest=True) |
| 189 | + |
| 190 | +if __name__ == "__main__": |
| 191 | + agent.run() |
0 commit comments