Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files
agents.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import yfinance as yf
|
| 2 |
+
import requests
|
| 3 |
+
from bs4 import BeautifulSoup
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import numpy as np
|
| 6 |
+
from langchain_openai import ChatOpenAI
|
| 7 |
+
import os
|
| 8 |
+
import pytz
|
| 9 |
+
from typing import Dict, Any
|
| 10 |
+
from state import TraderState
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
|
| 13 |
+
os.environ["OPENAI_API_KEY"] = os.getenv("OPENROUTER_API_KEY", "")
|
| 14 |
+
llm = ChatOpenAI(model="openai/gpt-3.5-turbo", openai_api_base="https://openrouter.ai/api/v1", temperature=0.2)
|
| 15 |
+
|
| 16 |
+
ALPHA_VANTAGE_KEY = os.getenv("ALPHA_VANTAGE_API_KEY", "")
|
| 17 |
+
NEWSAPI_KEY = os.getenv("NEWSAPI_KEY", "")
|
| 18 |
+
|
| 19 |
+
def fetch_data(symbol: str, horizon: str) -> pd.DataFrame:
|
| 20 |
+
"""Fetch data precisely for each trading type"""
|
| 21 |
+
if horizon == "intraday":
|
| 22 |
+
period = "1d"
|
| 23 |
+
interval = "1m"
|
| 24 |
+
elif horizon == "scalping":
|
| 25 |
+
period = "1d"
|
| 26 |
+
interval = "1m"
|
| 27 |
+
elif horizon == "swing":
|
| 28 |
+
period = "3mo"
|
| 29 |
+
interval = "1d"
|
| 30 |
+
elif horizon == "momentum":
|
| 31 |
+
period = "6mo"
|
| 32 |
+
interval = "1d"
|
| 33 |
+
else: # long_term
|
| 34 |
+
period = "2y"
|
| 35 |
+
interval = "1d"
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
stock = yf.Ticker(symbol)
|
| 39 |
+
hist = stock.history(period=period, interval=interval)
|
| 40 |
+
if hist.empty or len(hist) < 50:
|
| 41 |
+
hist = stock.history(period="1y", interval="1d") # Fallback
|
| 42 |
+
if hist.empty:
|
| 43 |
+
raise ValueError(f"No data from yfinance for {symbol}")
|
| 44 |
+
|
| 45 |
+
eastern = pytz.timezone('US/Eastern')
|
| 46 |
+
ist = pytz.timezone('Asia/Kolkata')
|
| 47 |
+
hist.index = hist.index.tz_convert(eastern).tz_convert(ist)
|
| 48 |
+
|
| 49 |
+
print(f"Data fetched for {symbol} ({horizon}): {len(hist)} rows ({interval} intervals)")
|
| 50 |
+
return hist
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"Yfinance failed for {symbol}: {e}")
|
| 53 |
+
return pd.DataFrame()
|
| 54 |
+
|
| 55 |
+
def compute_indicators(hist: pd.DataFrame, horizon: str) -> Dict[str, Any]:
|
| 56 |
+
if hist.empty:
|
| 57 |
+
return {"error": "No data available", "current_price": 0, "rsi": 50, "macd": 0, "sma_20": 0, "ema_50": 0, "bb_upper": 0, "bb_lower": 0, "volume_avg": 0}
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
hist = hist.sort_index()
|
| 61 |
+
close = hist['Close']
|
| 62 |
+
if len(close) < 50:
|
| 63 |
+
return {"error": "Insufficient data"}
|
| 64 |
+
delta = close.diff()
|
| 65 |
+
gain = (delta.where(delta > 0, 0)).rolling(14).mean()
|
| 66 |
+
loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
|
| 67 |
+
rs = gain / loss
|
| 68 |
+
rsi = 100 - (100 / (1 + rs)).iloc[-1]
|
| 69 |
+
ema12 = close.ewm(12).mean()
|
| 70 |
+
ema26 = close.ewm(26).mean()
|
| 71 |
+
macd = ema12 - ema26
|
| 72 |
+
macd_value = macd.iloc[-1]
|
| 73 |
+
sma_20 = close.rolling(20).mean().iloc[-1]
|
| 74 |
+
ema_50 = close.ewm(50).mean().iloc[-1]
|
| 75 |
+
if len(close) >= 20:
|
| 76 |
+
std_bb = close.rolling(20).std()
|
| 77 |
+
bb_upper = (sma_20 + 2 * std_bb).iloc[-1]
|
| 78 |
+
bb_lower = (sma_20 - 2 * std_bb).iloc[-1]
|
| 79 |
+
else:
|
| 80 |
+
bb_upper = bb_lower = None
|
| 81 |
+
volume_avg = hist['Volume'].rolling(20).mean().iloc[-1] if 'Volume' in hist else 0
|
| 82 |
+
return {
|
| 83 |
+
"current_price": close.iloc[-1], "rsi": rsi, "macd": macd_value,
|
| 84 |
+
"sma_20": sma_20, "ema_50": ema_50, "bb_upper": bb_upper, "bb_lower": bb_lower,
|
| 85 |
+
"volume_avg": volume_avg
|
| 86 |
+
}
|
| 87 |
+
except Exception as e:
|
| 88 |
+
return {"error": str(e)}
|
| 89 |
+
|
| 90 |
+
def fetch_news_sentiment(symbol: str) -> dict:
|
| 91 |
+
"""
|
| 92 |
+
Fetch recent news from NewsAPI (free forever) and analyze sentiment.
|
| 93 |
+
"""
|
| 94 |
+
try:
|
| 95 |
+
base_symbol = symbol.replace(".NS", "").replace(".BO", "")
|
| 96 |
+
query = f'"{base_symbol}" OR "{base_symbol} stock"'
|
| 97 |
+
url = f"https://newsapi.org/v2/everything?q={query}&sortBy=publishedAt&apiKey={NEWSAPI_KEY}&pageSize=10&language=en"
|
| 98 |
+
response = requests.get(url, timeout=10)
|
| 99 |
+
data = response.json()
|
| 100 |
+
|
| 101 |
+
if 'articles' in data and data['articles']:
|
| 102 |
+
articles = data['articles'][:5]
|
| 103 |
+
headlines = [art['title'] for art in articles]
|
| 104 |
+
descriptions = [art.get('description', '') for art in articles]
|
| 105 |
+
|
| 106 |
+
positive_words = ['gain', 'rise', 'profit', 'growth', 'earnings', 'bullish', 'up', 'positive', 'strong']
|
| 107 |
+
negative_words = ['loss', 'fall', 'decline', 'drop', 'bearish', 'down', 'negative', 'weak', 'crash']
|
| 108 |
+
|
| 109 |
+
pos_count = sum(1 for h in headlines + descriptions for word in positive_words if word in h.lower())
|
| 110 |
+
neg_count = sum(1 for h in headlines + descriptions for word in negative_words if word in h.lower())
|
| 111 |
+
|
| 112 |
+
sentiment = 'positive' if pos_count > neg_count else 'negative' if neg_count > pos_count else 'neutral'
|
| 113 |
+
summary = f"Recent news sentiment: {sentiment}. Key headlines: {'; '.join(headlines[:3])}."
|
| 114 |
+
return {'sentiment': sentiment, 'headlines': headlines, 'summary': summary}
|
| 115 |
+
else:
|
| 116 |
+
return {'sentiment': 'neutral', 'headlines': ["No recent news found."], 'summary': "No recent news available; sentiment neutral."}
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"NewsAPI error: {e}")
|
| 119 |
+
return {'sentiment': 'neutral', 'headlines': ["News fetch failed."], 'summary': "News unavailable; assuming neutral sentiment."}
|
| 120 |
+
|
| 121 |
+
def summarize_features(indicators: Dict[str, Any], hist: pd.DataFrame, news: list, action: str, symbol: str, news_sentiment: str = 'neutral') -> str:
|
| 122 |
+
if indicators.get("error"):
|
| 123 |
+
try:
|
| 124 |
+
stock = yf.Ticker(symbol)
|
| 125 |
+
info = stock.info
|
| 126 |
+
price = info.get('currentPrice', 0)
|
| 127 |
+
change = info.get('regularMarketChangePercent', 0)
|
| 128 |
+
signals = [f"Recent change: {change:.1f}%"]
|
| 129 |
+
if change > 0:
|
| 130 |
+
signals.append("Positive momentum")
|
| 131 |
+
else:
|
| 132 |
+
signals.append("Negative momentum")
|
| 133 |
+
return f"Fallback Signals: {', '.join(signals)}; News: {news[0] if news else 'Neutral'}"
|
| 134 |
+
except:
|
| 135 |
+
return "No reliable data; default to caution."
|
| 136 |
+
|
| 137 |
+
rsi = indicators.get('rsi', 50)
|
| 138 |
+
macd = indicators.get('macd', 0)
|
| 139 |
+
price = indicators.get('current_price', 0)
|
| 140 |
+
sma_20 = indicators.get('sma_20', price)
|
| 141 |
+
bb_upper = indicators.get('bb_upper', price * 1.1)
|
| 142 |
+
bb_lower = indicators.get('bb_lower', price * 0.9)
|
| 143 |
+
|
| 144 |
+
recent_prices = hist['Close'].tail(10) if not hist.empty else pd.Series([price])
|
| 145 |
+
trend = "upward" if recent_prices.iloc[-1] > recent_prices.iloc[0] else "downward"
|
| 146 |
+
change_pct = ((recent_prices.iloc[-1] - recent_prices.iloc[0]) / recent_prices.iloc[0]) * 100 if len(recent_prices) > 1 else 0
|
| 147 |
+
|
| 148 |
+
signals = []
|
| 149 |
+
if rsi < 30:
|
| 150 |
+
signals.append("Oversold RSI (potential buy reversal)")
|
| 151 |
+
elif rsi > 70:
|
| 152 |
+
signals.append("Overbought RSI (sell risk)")
|
| 153 |
+
if macd > 0:
|
| 154 |
+
signals.append("Bullish MACD")
|
| 155 |
+
else:
|
| 156 |
+
signals.append("Bearish MACD")
|
| 157 |
+
if price < bb_lower:
|
| 158 |
+
signals.append("Price near support (buy opportunity)")
|
| 159 |
+
elif price > bb_upper:
|
| 160 |
+
signals.append("Price near resistance (sell signal)")
|
| 161 |
+
if price > sma_20:
|
| 162 |
+
signals.append("Above SMA20 (momentum)")
|
| 163 |
+
else:
|
| 164 |
+
signals.append("Below SMA20 (weakness)")
|
| 165 |
+
|
| 166 |
+
news_sentiment_str = f"News Sentiment: {news_sentiment.capitalize()} (e.g., {news[0] if news else 'No updates'})"
|
| 167 |
+
|
| 168 |
+
action_bias = "favorable for buying" if action == "buy" and trend == "upward" else "favorable for selling" if action == "sell" and trend == "downward" else "mixed"
|
| 169 |
+
|
| 170 |
+
features = [
|
| 171 |
+
f"Trend: {trend} ({change_pct:.1f}% change)",
|
| 172 |
+
f"Key Signals: {', '.join(signals)}",
|
| 173 |
+
news_sentiment_str,
|
| 174 |
+
f"Overall Bias for {action}: {action_bias}"
|
| 175 |
+
]
|
| 176 |
+
return "; ".join(features)
|
| 177 |
+
|
| 178 |
+
def calculate_holding_metrics(hist: pd.DataFrame, holding_period: str, current_price: float) -> Dict[str, Any]:
|
| 179 |
+
if not holding_period or hist.empty:
|
| 180 |
+
return {}
|
| 181 |
+
|
| 182 |
+
import re
|
| 183 |
+
match = re.search(r'(\d+)\s*(month|week|day)', holding_period.lower())
|
| 184 |
+
if match:
|
| 185 |
+
num, unit = int(match.group(1)), match.group(2)
|
| 186 |
+
days = num * 30 if unit == 'month' else num * 7 if unit == 'week' else num
|
| 187 |
+
purchase_date = datetime.now(pytz.timezone('Asia/Kolkata')) - timedelta(days=days)
|
| 188 |
+
hist_before = hist[hist.index < purchase_date]
|
| 189 |
+
if not hist_before.empty:
|
| 190 |
+
purchase_price = hist_before['Close'].iloc[-1]
|
| 191 |
+
else:
|
| 192 |
+
# Use oldest price in data if exact date not found
|
| 193 |
+
purchase_price = hist['Close'].iloc[0] if not hist.empty else 0
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
live_price = yf.Ticker(hist.index.name or 'AAPL').info.get('currentPrice', current_price)
|
| 197 |
+
if live_price <= 0:
|
| 198 |
+
live_price = current_price
|
| 199 |
+
except:
|
| 200 |
+
live_price = current_price
|
| 201 |
+
|
| 202 |
+
if purchase_price > 0:
|
| 203 |
+
holding_return = ((live_price - purchase_price) / purchase_price) * 100
|
| 204 |
+
else:
|
| 205 |
+
holding_return = 0
|
| 206 |
+
|
| 207 |
+
return {
|
| 208 |
+
"purchase_price": purchase_price,
|
| 209 |
+
"holding_return": holding_return,
|
| 210 |
+
"days_held": days,
|
| 211 |
+
"pnl": "profit" if holding_return > 0 else "loss"
|
| 212 |
+
}
|
| 213 |
+
return {}
|
| 214 |
+
|
| 215 |
+
def predict(horizon: str, indicators: Dict[str, Any], hist: pd.DataFrame, news: list, news_sentiment: str = 'neutral') -> str:
|
| 216 |
+
if hist.empty or indicators.get("error"):
|
| 217 |
+
return "Unable to predict due to lack of data."
|
| 218 |
+
|
| 219 |
+
rsi = indicators.get('rsi', 50)
|
| 220 |
+
macd = indicators.get('macd', 0)
|
| 221 |
+
close = hist['Close']
|
| 222 |
+
|
| 223 |
+
if horizon == "intraday":
|
| 224 |
+
# minute changes for same-day prediction
|
| 225 |
+
recent_change = ((close.iloc[-1] - close.iloc[-10]) / close.iloc[-10]) * 100 if len(close) >= 10 else 0 # Last 10 minutes
|
| 226 |
+
prob = 70 if (rsi < 40 and macd > 0) else 50 if rsi > 60 else 30
|
| 227 |
+
direction = "rise" if recent_change > 0 else "fall"
|
| 228 |
+
pct = abs(recent_change) * 0.8
|
| 229 |
+
sentiment_boost = 1.15 if news_sentiment == 'positive' else 0.85 if news_sentiment == 'negative' else 1.0
|
| 230 |
+
prob = min(95, int(prob * sentiment_boost))
|
| 231 |
+
return f"{prob}% chance of {pct:.1f}% {direction} in the next 1-2 hours (same-day intraday) based on RSI {rsi:.1f}, MACD {macd:.2f}, recent {recent_change:.1f}% change, and {news_sentiment} news."
|
| 232 |
+
|
| 233 |
+
elif horizon == "scalping":
|
| 234 |
+
# Ultra-short minute prediction
|
| 235 |
+
recent_change = ((close.iloc[-1] - close.iloc[-5]) / close.iloc[-5]) * 100 if len(close) >= 5 else 0 # Last 5 minutes
|
| 236 |
+
prob = 80 if rsi < 35 else 40
|
| 237 |
+
direction = "quick rise" if rsi < 35 else "quick fall"
|
| 238 |
+
pct = 1.5
|
| 239 |
+
return f"{prob}% chance of {pct:.1f}% {direction} in the next 5-10 minutes (ultra-short scalping) based on RSI ({rsi:.1f})."
|
| 240 |
+
|
| 241 |
+
elif horizon == "swing":
|
| 242 |
+
# Formula: Weekly changes for 1-4 week prediction
|
| 243 |
+
avg_weekly = close.tail(20).pct_change().mean() * 100 if len(close) >= 20 else 0 # Approx weekly
|
| 244 |
+
prob = 65 if avg_weekly > 0 and macd > 0 else 45 if avg_weekly < 0 else 50
|
| 245 |
+
direction = "up" if avg_weekly > 0 else "down"
|
| 246 |
+
pct = abs(avg_weekly) * 3
|
| 247 |
+
return f"{prob}% chance of {pct:.1f}% {direction} over the next 1-4 weeks (swing trading)."
|
| 248 |
+
|
| 249 |
+
elif horizon == "momentum":
|
| 250 |
+
# Formula: Momentum over weeks
|
| 251 |
+
prob = 70 if macd > 0 and rsi < 60 else 45
|
| 252 |
+
direction = "continued rise" if macd > 0 else "fall"
|
| 253 |
+
pct = 4.0
|
| 254 |
+
return f"{prob}% chance of {pct:.1f}% {direction} over the next 2-4 weeks (momentum trading) based on MACD ({macd:.2f})."
|
| 255 |
+
|
| 256 |
+
else: # long_term
|
| 257 |
+
# Formula: Monthly changes for months/years
|
| 258 |
+
avg_monthly = close.tail(60).pct_change().mean() * 100 if len(close) >= 60 else 0
|
| 259 |
+
prob = 70 if avg_monthly > 1 and macd > 0 else 40 if avg_monthly < 0 else 50
|
| 260 |
+
direction = "growth" if avg_monthly > 0 else "decline"
|
| 261 |
+
pct = abs(avg_monthly) * 2
|
| 262 |
+
sentiment_boost = 1.15 if news_sentiment == 'positive' else 0.85 if news_sentiment == 'negative' else 1.0
|
| 263 |
+
prob = min(95, int(prob * sentiment_boost))
|
| 264 |
+
return f"{prob}% chance of {pct:.1f}% {direction} over months (position trading) based on {avg_monthly:.1f}% avg monthly change and {news_sentiment} news."
|
| 265 |
+
|
| 266 |
+
def hunter_agent(state: TraderState) -> TraderState:
|
| 267 |
+
try:
|
| 268 |
+
symbol = state['input_symbol']
|
| 269 |
+
action = state['action']
|
| 270 |
+
horizon = state['horizon']
|
| 271 |
+
|
| 272 |
+
if state.get('raw_data') and state['raw_data'].get('hist') is not None:
|
| 273 |
+
hist = state['raw_data']['hist']
|
| 274 |
+
else:
|
| 275 |
+
hist = fetch_data(symbol, horizon)
|
| 276 |
+
if state.get('raw_data') is None:
|
| 277 |
+
state['raw_data'] = {}
|
| 278 |
+
state['raw_data']['hist'] = hist
|
| 279 |
+
|
| 280 |
+
indicators = compute_indicators(hist, horizon)
|
| 281 |
+
news_data = fetch_news_sentiment(symbol)
|
| 282 |
+
headlines = news_data['headlines']
|
| 283 |
+
news_sentiment = news_data['sentiment']
|
| 284 |
+
|
| 285 |
+
features_summary = summarize_features(indicators, hist, headlines, action, symbol, news_sentiment)
|
| 286 |
+
prediction = predict(horizon, indicators, hist, headlines, news_sentiment)
|
| 287 |
+
holding_metrics = calculate_holding_metrics(hist, state.get('holding_period'), indicators.get('current_price', 0))
|
| 288 |
+
|
| 289 |
+
state['raw_data']['indicators'] = indicators
|
| 290 |
+
state['raw_data']['news'] = headlines
|
| 291 |
+
state['raw_data']['features'] = features_summary
|
| 292 |
+
state['raw_data']['prediction'] = prediction
|
| 293 |
+
state['raw_data']['holding'] = holding_metrics
|
| 294 |
+
state['raw_data']['news_sentiment'] = news_sentiment
|
| 295 |
+
|
| 296 |
+
if holding_metrics:
|
| 297 |
+
features_summary += f"; Holding: {holding_metrics['days_held']} days, {holding_metrics['holding_return']:.1f}% {holding_metrics['pnl']}"
|
| 298 |
+
|
| 299 |
+
rsi = indicators.get('rsi', 50)
|
| 300 |
+
macd = indicators.get('macd', 0)
|
| 301 |
+
|
| 302 |
+
if action == "buy":
|
| 303 |
+
if rsi < 30 and macd > 0:
|
| 304 |
+
state['claim'] = f"Strong buy signal: Oversold RSI ({rsi:.1f}) with bullish MACD ({macd:.2f}) indicates reversal."
|
| 305 |
+
elif rsi < 40 or macd > 5:
|
| 306 |
+
state['claim'] = f"Moderate buy signal: RSI at {rsi:.1f}, MACD at {macd:.2f} shows positive momentum."
|
| 307 |
+
else:
|
| 308 |
+
state['claim'] = f"Weak buy signal: RSI ({rsi:.1f}) and MACD ({macd:.2f}) don't strongly support buying now."
|
| 309 |
+
else:
|
| 310 |
+
if rsi > 70 or macd < -5:
|
| 311 |
+
state['claim'] = f"Strong sell signal: Overbought RSI ({rsi:.1f}) or weak MACD ({macd:.2f}) suggests taking profits."
|
| 312 |
+
elif rsi > 60 or macd < 0:
|
| 313 |
+
state['claim'] = f"Moderate sell signal: RSI at {rsi:.1f}, MACD at {macd:.2f} shows weakening momentum."
|
| 314 |
+
else:
|
| 315 |
+
state['claim'] = f"Weak sell signal: RSI ({rsi:.1f}) and MACD ({macd:.2f}) don't strongly support selling now."
|
| 316 |
+
|
| 317 |
+
return state
|
| 318 |
+
except Exception as e:
|
| 319 |
+
print(f"Error in hunter_agent: {e}")
|
| 320 |
+
return state # Always return state
|
| 321 |
+
|
| 322 |
+
def skeptic_agent(state: TraderState) -> TraderState:
|
| 323 |
+
try:
|
| 324 |
+
# Use cached raw_data
|
| 325 |
+
raw_data = state.get('raw_data', {}) or {}
|
| 326 |
+
features_summary = raw_data.get("features", "No features available.")
|
| 327 |
+
action = state['action']
|
| 328 |
+
prompt = f"Counter claim '{state.get('claim', '')}' for {action}: {features_summary}. Provide 1 specific risk."
|
| 329 |
+
try:
|
| 330 |
+
state['skepticism'] = llm.invoke(prompt).content.strip()
|
| 331 |
+
except:
|
| 332 |
+
rsi = raw_data.get("indicators", {}).get('rsi', 50)
|
| 333 |
+
state['skepticism'] = f"Risk: RSI at {rsi} indicates potential reversal."
|
| 334 |
+
return state
|
| 335 |
+
except Exception as e:
|
| 336 |
+
print(f"Error in skeptic_agent: {e}")
|
| 337 |
+
return state # Always return state
|
| 338 |
+
|
| 339 |
+
def calibrator_agent(state: TraderState) -> TraderState:
|
| 340 |
+
try:
|
| 341 |
+
raw_data = state.get('raw_data', {}) or {}
|
| 342 |
+
indicators = raw_data.get("indicators", {})
|
| 343 |
+
action = state['action']
|
| 344 |
+
news_sentiment = raw_data.get('news_sentiment', 'neutral')
|
| 345 |
+
holding = raw_data.get('holding', {})
|
| 346 |
+
|
| 347 |
+
state['iterations'] = state.get('iterations', 0) + 1
|
| 348 |
+
|
| 349 |
+
rsi = indicators.get('rsi', 50)
|
| 350 |
+
macd = indicators.get('macd', 0)
|
| 351 |
+
|
| 352 |
+
base = 50
|
| 353 |
+
|
| 354 |
+
if action == "buy":
|
| 355 |
+
if rsi < 30:
|
| 356 |
+
base += 30
|
| 357 |
+
elif rsi < 40:
|
| 358 |
+
base += 15
|
| 359 |
+
elif rsi > 70:
|
| 360 |
+
base -= 25
|
| 361 |
+
elif rsi > 60:
|
| 362 |
+
base -= 10
|
| 363 |
+
|
| 364 |
+
if macd > 5:
|
| 365 |
+
base += 20
|
| 366 |
+
elif macd > 0:
|
| 367 |
+
base += 10
|
| 368 |
+
elif macd < -5:
|
| 369 |
+
base -= 20
|
| 370 |
+
elif macd < 0:
|
| 371 |
+
base -= 10
|
| 372 |
+
|
| 373 |
+
if news_sentiment == 'positive':
|
| 374 |
+
base += 15
|
| 375 |
+
elif news_sentiment == 'negative':
|
| 376 |
+
base -= 15
|
| 377 |
+
|
| 378 |
+
else:
|
| 379 |
+
if rsi > 70:
|
| 380 |
+
base += 30
|
| 381 |
+
elif rsi > 60:
|
| 382 |
+
base += 15
|
| 383 |
+
elif rsi < 30:
|
| 384 |
+
base -= 25
|
| 385 |
+
elif rsi < 40:
|
| 386 |
+
base -= 10
|
| 387 |
+
|
| 388 |
+
if macd < -5:
|
| 389 |
+
base += 20
|
| 390 |
+
elif macd < 0:
|
| 391 |
+
base += 10
|
| 392 |
+
elif macd > 5:
|
| 393 |
+
base -= 20
|
| 394 |
+
elif macd > 0:
|
| 395 |
+
base -= 10
|
| 396 |
+
|
| 397 |
+
if news_sentiment == 'negative':
|
| 398 |
+
base += 15
|
| 399 |
+
elif news_sentiment == 'positive':
|
| 400 |
+
base -= 15
|
| 401 |
+
|
| 402 |
+
if holding:
|
| 403 |
+
pnl_pct = holding.get('holding_return', 0)
|
| 404 |
+
if action == "sell":
|
| 405 |
+
if pnl_pct > 10:
|
| 406 |
+
base += 15
|
| 407 |
+
elif pnl_pct > 5:
|
| 408 |
+
base += 10
|
| 409 |
+
elif pnl_pct < -10:
|
| 410 |
+
base += 20
|
| 411 |
+
elif pnl_pct < -5:
|
| 412 |
+
base += 10
|
| 413 |
+
|
| 414 |
+
state['confidence'] = min(95, max(5, base))
|
| 415 |
+
state['stop'] = state['iterations'] >= 5 or state['confidence'] >= 85 or state['confidence'] <= 15
|
| 416 |
+
|
| 417 |
+
return state
|
| 418 |
+
except Exception as e:
|
| 419 |
+
print(f"Error in calibrator_agent: {e}")
|
| 420 |
+
return state
|
graph.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import StateGraph, END
|
| 2 |
+
from agents import hunter_agent, skeptic_agent, calibrator_agent
|
| 3 |
+
from state import TraderState
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
graph = StateGraph(TraderState)
|
| 7 |
+
|
| 8 |
+
graph.add_node("hunter", hunter_agent)
|
| 9 |
+
graph.add_node("skeptic", skeptic_agent)
|
| 10 |
+
graph.add_node("calibrator", calibrator_agent)
|
| 11 |
+
|
| 12 |
+
# hunter -> skeptic -> calibrator
|
| 13 |
+
graph.add_edge("hunter", "skeptic")
|
| 14 |
+
graph.add_edge("skeptic", "calibrator")
|
| 15 |
+
|
| 16 |
+
# Conditional edge from calibrator: If stop is True that means it end and go to END; else it will loop back to hunter
|
| 17 |
+
def should_continue(state: TraderState) -> str:
|
| 18 |
+
return END if state.get('stop', False) else "hunter"
|
| 19 |
+
|
| 20 |
+
graph.add_conditional_edges("calibrator", should_continue)
|
| 21 |
+
|
| 22 |
+
graph.set_entry_point("hunter")
|
| 23 |
+
|
| 24 |
+
compiled_graph = graph.compile()
|
main.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from state import TraderState
|
| 2 |
+
from agents import hunter_agent, skeptic_agent, calibrator_agent, fetch_data
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from langchain_openai import ChatOpenAI
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import json
|
| 8 |
+
import pytz
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
import yfinance as yf
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
os.environ["OPENAI_API_KEY"] = os.getenv("OPENROUTER_API_KEY", "")
|
| 14 |
+
llm_parser = ChatOpenAI(model="openai/gpt-3.5-turbo", openai_api_base="https://openrouter.ai/api/v1", temperature=0.0)
|
| 15 |
+
|
| 16 |
+
def resolve_symbol(user_input: str) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Handles Indian stocks (.NS/.BO).
|
| 19 |
+
"""
|
| 20 |
+
prompt = f"""
|
| 21 |
+
Analyze this user query: "{user_input}".
|
| 22 |
+
- Extract the company/brand name.
|
| 23 |
+
- Infer the likely stock market.
|
| 24 |
+
- Suggest the stock ticker symbol with the appropriate exchange suffix
|
| 25 |
+
(e.g., "GOOGL" for US, "TCS.NS" for NSE India).
|
| 26 |
+
- If it is an Indian stock, it MUST end with ".NS" or ".BO".
|
| 27 |
+
- Respond only with the symbol. If unsure, say "UNKNOWN".
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
suggested_symbol = llm_parser.invoke(prompt).content.strip().upper()
|
| 31 |
+
if suggested_symbol == "UNKNOWN":
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
if not (suggested_symbol.endswith(".NS") or suggested_symbol.endswith(".BO")):
|
| 35 |
+
is_india_prompt = f"Is the stock/company '{suggested_symbol}' from the query '{user_input}' traded on Indian exchanges? Answer only YES or NO."
|
| 36 |
+
is_india = llm_parser.invoke(is_india_prompt).content.strip().upper()
|
| 37 |
+
|
| 38 |
+
if "YES" in is_india:
|
| 39 |
+
suggested_symbol += ".NS"
|
| 40 |
+
|
| 41 |
+
# Try NSE, fallback to BSE
|
| 42 |
+
ticker = yf.Ticker(suggested_symbol)
|
| 43 |
+
|
| 44 |
+
if not ticker.history(period="1d").empty:
|
| 45 |
+
return suggested_symbol
|
| 46 |
+
|
| 47 |
+
# If NSE (.NS) failed, it might be a BSE-only stock
|
| 48 |
+
if suggested_symbol.endswith(".NS"):
|
| 49 |
+
bse_symbol = suggested_symbol.replace(".NS", ".BO")
|
| 50 |
+
if not yf.Ticker(bse_symbol).history(period="1d").empty:
|
| 51 |
+
return bse_symbol
|
| 52 |
+
# Fallback
|
| 53 |
+
refine_prompt = f"The symbol '{suggested_symbol}' didn't validate. Suggest the EXACT ticker for: '{user_input}'. Respond ONLY with the ticker."
|
| 54 |
+
refined_symbol = llm_parser.invoke(refine_prompt).content.strip().upper()
|
| 55 |
+
|
| 56 |
+
# The same suffix logic
|
| 57 |
+
if not (refined_symbol.endswith(".NS") or refined_symbol.endswith(".BO")):
|
| 58 |
+
if "YES" in is_india:
|
| 59 |
+
refined_symbol += ".NS"
|
| 60 |
+
|
| 61 |
+
if not yf.Ticker(refined_symbol).history(period="1d").empty:
|
| 62 |
+
return refined_symbol
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f"Symbol resolution error: {e}")
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def parse_query(user_input: str) -> tuple:
|
| 70 |
+
"""FIXED: Proper horizon detection based on correct trading logic"""
|
| 71 |
+
prompt = f"""
|
| 72 |
+
Analyze this user query: "{user_input}".
|
| 73 |
+
|
| 74 |
+
TRADING HORIZONS (CRITICAL):
|
| 75 |
+
- "intraday": same day buy and same day sell (hours)
|
| 76 |
+
- "scalping": ultra-short (minutes)
|
| 77 |
+
- "swing": buy this week, sell next week (1-4 weeks)
|
| 78 |
+
- "momentum": trend-following over weeks (2-4 weeks)
|
| 79 |
+
- "long_term": months to years (position trading, e.g., "bought 1 month ago")
|
| 80 |
+
|
| 81 |
+
Extract:
|
| 82 |
+
- stock symbol (e.g., "HINDUNILVR.NS")
|
| 83 |
+
- action: "buy" or "sell" (default "buy")
|
| 84 |
+
- horizon: match to above definitions
|
| 85 |
+
- date: "today", "tomorrow", or future (e.g., "after 2 weeks")
|
| 86 |
+
- holding_period: If mentioned (e.g., "1 week ago", "a week ago"), provide as "X unit ago". Else, None.
|
| 87 |
+
|
| 88 |
+
CRITICAL: If user says "bought X time ago" → horizon = "long_term"
|
| 89 |
+
If user says "tomorrow" without history → horizon = "intraday" (same-day strategy for next day)
|
| 90 |
+
|
| 91 |
+
Respond ONLY in JSON: {{"symbol": "HINDUNILVR.NS", "action": "sell", "horizon": "long_term", "date": "2025-12-27", "holding_period": "1 week ago"}}. Respond sholud be clean.
|
| 92 |
+
"""
|
| 93 |
+
try:
|
| 94 |
+
response = llm_parser.invoke(prompt).content.strip()
|
| 95 |
+
parsed = json.loads(response)
|
| 96 |
+
symbol = parsed.get("symbol")
|
| 97 |
+
action = parsed.get("action", "buy")
|
| 98 |
+
horizon = parsed.get("horizon", "intraday")
|
| 99 |
+
date_str = parsed.get("date")
|
| 100 |
+
holding_period = parsed.get("holding_period")
|
| 101 |
+
|
| 102 |
+
# Override horizon detection with explicit logic
|
| 103 |
+
if "ago" in user_input.lower() or "bought" in user_input.lower() or "purchased" in user_input.lower():
|
| 104 |
+
horizon = "long_term"
|
| 105 |
+
elif "tomorrow" in user_input.lower() and "ago" not in user_input.lower():
|
| 106 |
+
horizon = "intraday"
|
| 107 |
+
elif "week" in user_input.lower() and "ago" not in user_input.lower():
|
| 108 |
+
horizon = "swing"
|
| 109 |
+
elif "minute" in user_input.lower() or "scalp" in user_input.lower():
|
| 110 |
+
horizon = "scalping"
|
| 111 |
+
elif "month" in user_input.lower() and "ago" in user_input.lower():
|
| 112 |
+
horizon = "long_term"
|
| 113 |
+
|
| 114 |
+
ist = pytz.timezone('Asia/Kolkata')
|
| 115 |
+
now_ist = datetime.now(ist)
|
| 116 |
+
date = None
|
| 117 |
+
if date_str == "today":
|
| 118 |
+
date = now_ist.strftime("%Y-%m-%d")
|
| 119 |
+
elif date_str == "tomorrow":
|
| 120 |
+
date = (now_ist + timedelta(days=1)).strftime("%Y-%m-%d")
|
| 121 |
+
elif date_str and "after" in user_input.lower():
|
| 122 |
+
match = re.search(r'after\s+(\d+)\s*(week|month)', user_input.lower())
|
| 123 |
+
if match:
|
| 124 |
+
num, unit = int(match.group(1)), match.group(2)
|
| 125 |
+
days = num * 7 if unit == "week" else num * 30
|
| 126 |
+
date = (now_ist + timedelta(days=days)).strftime("%Y-%m-%d")
|
| 127 |
+
|
| 128 |
+
return symbol, date, action, horizon, holding_period
|
| 129 |
+
except Exception as e:
|
| 130 |
+
print(f"Parsing error: {e}. Fallback.")
|
| 131 |
+
symbol = resolve_symbol(user_input)
|
| 132 |
+
action = "sell" if "sell" in user_input.lower() else "buy"
|
| 133 |
+
|
| 134 |
+
# CRITICAL FIX: Correct horizon fallback
|
| 135 |
+
if "ago" in user_input.lower() or "month" in user_input.lower() or "bought" in user_input.lower():
|
| 136 |
+
horizon = "long_term"
|
| 137 |
+
elif "week" in user_input.lower() and "ago" not in user_input.lower():
|
| 138 |
+
horizon = "swing"
|
| 139 |
+
elif "minute" in user_input.lower():
|
| 140 |
+
horizon = "scalping"
|
| 141 |
+
elif "tomorrow" in user_input.lower():
|
| 142 |
+
horizon = "intraday"
|
| 143 |
+
else:
|
| 144 |
+
horizon = "intraday"
|
| 145 |
+
|
| 146 |
+
ist = pytz.timezone('Asia/Kolkata')
|
| 147 |
+
now_ist = datetime.now(ist)
|
| 148 |
+
date = now_ist.strftime("%Y-%m-%d") if "today" in user_input.lower() else (now_ist + timedelta(days=1)).strftime("%Y-%m-%d") if "tomorrow" in user_input.lower() else None
|
| 149 |
+
|
| 150 |
+
match = re.search(r'(\d+)\s*(week|month|day)\s*ago', user_input.lower())
|
| 151 |
+
holding_period = f"{match.group(1)} {match.group(2)} ago" if match else None
|
| 152 |
+
|
| 153 |
+
return symbol, date, action, horizon, holding_period
|
| 154 |
+
|
| 155 |
+
def generate_alert_message(state: TraderState, symbol: str, action: str, horizon: str) -> str:
|
| 156 |
+
confidence = state['confidence']
|
| 157 |
+
raw_data = state.get('raw_data', {}) or {}
|
| 158 |
+
indicators = raw_data.get("indicators", {})
|
| 159 |
+
news = raw_data.get("news", [])
|
| 160 |
+
prediction = raw_data.get("prediction", "No prediction available.")
|
| 161 |
+
holding = raw_data.get("holding", {})
|
| 162 |
+
claim = state.get('claim', 'No claim.')
|
| 163 |
+
skepticism = state.get('skepticism', 'No skepticism.')
|
| 164 |
+
features = raw_data.get("features", "")
|
| 165 |
+
|
| 166 |
+
rsi = indicators.get('rsi', 50)
|
| 167 |
+
macd = indicators.get('macd', 0)
|
| 168 |
+
current_price = indicators.get('current_price', 1)
|
| 169 |
+
|
| 170 |
+
volatility = 0
|
| 171 |
+
if current_price > 0:
|
| 172 |
+
bb_upper = indicators.get('bb_upper', current_price * 1.1)
|
| 173 |
+
bb_lower = indicators.get('bb_lower', current_price * 0.9)
|
| 174 |
+
volatility = (bb_upper - bb_lower) / current_price * 100
|
| 175 |
+
|
| 176 |
+
risk_adjustment = min(20, volatility / 5) if volatility > 0 else 0
|
| 177 |
+
|
| 178 |
+
if holding:
|
| 179 |
+
pnl_adjustment = 10 if holding.get('holding_return', 0) > 5 else -10 if holding.get('holding_return', 0) < -5 else 0
|
| 180 |
+
allocation = max(0, min(100, confidence - risk_adjustment + pnl_adjustment))
|
| 181 |
+
else:
|
| 182 |
+
allocation = max(0, min(100, confidence - risk_adjustment))
|
| 183 |
+
|
| 184 |
+
if indicators.get("error") == "No data available":
|
| 185 |
+
return f" No Data Available\nSorry, we couldn't fetch reliable data for {symbol}. Check official sources like NSE or Yahoo Finance. Try searching for '{symbol}.NS' if it's an Indian stock."
|
| 186 |
+
|
| 187 |
+
# HORIZON-AWARE best date suggestion
|
| 188 |
+
horizon_desc = {
|
| 189 |
+
"intraday": "same-day trading (hours)",
|
| 190 |
+
"scalping": "ultra-short (minutes)",
|
| 191 |
+
"swing": "1-4 weeks",
|
| 192 |
+
"momentum": "2-4 weeks",
|
| 193 |
+
"long_term": "months to years"
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
best_date_prompt = f"""
|
| 197 |
+
Based on prediction: '{prediction}', indicators: RSI {rsi:.1f}, MACD {macd:.2f}, trend: {features.split(';')[0] if features else 'N/A'}.
|
| 198 |
+
User query involves '{action}' for '{horizon}' ({horizon_desc.get(horizon, horizon)}).
|
| 199 |
+
Suggest the SPECIFIC best date/time to {action} {symbol} for maximum benefit.
|
| 200 |
+
- For intraday/scalping: suggest time of day (e.g., "Sell tomorrow at 10:30 AM IST for 1.5% gain")
|
| 201 |
+
- For swing/momentum: suggest specific date within the horizon (e.g., "Buy on Jan 2, 2026")
|
| 202 |
+
- For long_term: suggest holding duration (e.g., "Hold until Feb 2026 for 8% gain")
|
| 203 |
+
Include % potential gain/loss. Be accurate and align with user intent.
|
| 204 |
+
"""
|
| 205 |
+
try:
|
| 206 |
+
best_date = llm_parser.invoke(best_date_prompt).content.strip()
|
| 207 |
+
except:
|
| 208 |
+
best_date = f"Based on trends, {action} within {horizon_desc.get(horizon, horizon)} for potential 1-3% {'gain' if action == 'buy' else 'exit'}."
|
| 209 |
+
|
| 210 |
+
why_prompt = f"""
|
| 211 |
+
Based on claim: '{claim}', skepticism: '{skepticism}', features: '{features}', prediction: '{prediction}', holding: '{holding}'.
|
| 212 |
+
RSI is {rsi:.1f} (low = oversold/buy signal, high = overbought/sell signal). MACD is {macd:.2f} (positive = bullish, negative = bearish).
|
| 213 |
+
Explain why the signal is Green/Yellow/Red for {action} {symbol} ({horizon} = {horizon_desc.get(horizon, horizon)}).
|
| 214 |
+
Use layman terms, reference indicators accurately, tie to data. Be genuine—align with RSI/MACD signals.
|
| 215 |
+
"""
|
| 216 |
+
try:
|
| 217 |
+
why = llm_parser.invoke(why_prompt).content.strip()
|
| 218 |
+
except:
|
| 219 |
+
why = f"Based on RSI ({rsi:.1f}) and MACD ({macd:.2f}), conditions are {'favorable' if confidence > 60 else 'mixed' if confidence > 40 else 'unfavorable'} for {action}."
|
| 220 |
+
|
| 221 |
+
if confidence >= 70:
|
| 222 |
+
color = " Green: 'Yes, Go Ahead!'"
|
| 223 |
+
should_action = f"Yes, {action} now. Allocate {allocation:.0f}% as signals are strong."
|
| 224 |
+
elif confidence >= 40:
|
| 225 |
+
color = " Yellow: 'Wait and Watch'"
|
| 226 |
+
should_action = f"Maybe, monitor closely. Allocate {allocation:.0f}% cautiously due to mixed signals and potential risk."
|
| 227 |
+
else:
|
| 228 |
+
color = " Red: 'No, Stop!'"
|
| 229 |
+
should_action = f"No, avoid {action}. Signals are weak; wait for better conditions."
|
| 230 |
+
|
| 231 |
+
holding_summary = ""
|
| 232 |
+
if holding:
|
| 233 |
+
h_return = holding['holding_return']
|
| 234 |
+
holding_summary = f"\n**Holding Summary**: Purchased {holding['days_held']} days ago at ₹{holding['purchase_price']:.2f}, current: ₹{holding.get('current_price', current_price):.2f}, return: {h_return:.1f}% ({holding['pnl']})."
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
if h_return > 0:
|
| 238 |
+
advice = "lock in gains"
|
| 239 |
+
elif h_return < 0:
|
| 240 |
+
advice = "wait for recovery or cut losses"
|
| 241 |
+
else:
|
| 242 |
+
advice = "exit at breakeven"
|
| 243 |
+
|
| 244 |
+
should_action += f" Based on {h_return:.1f}% {holding['pnl']}, {advice}."
|
| 245 |
+
|
| 246 |
+
report = f"""
|
| 247 |
+
--- AI Trader Analyzer Report ---
|
| 248 |
+
Symbol: {symbol}
|
| 249 |
+
**Current Price**: {current_price:,.4f}
|
| 250 |
+
Action: {action.capitalize()} ({horizon.replace('_', '-').capitalize()} = {horizon_desc.get(horizon, horizon)}) "/n"
|
| 251 |
+
Date: {state.get('query_date', 'N/A')} (IST)
|
| 252 |
+
Signal: {color}
|
| 253 |
+
Confidence: {confidence}%
|
| 254 |
+
Key Metrics: RSI {rsi:.1f}, MACD {macd:.2f}, Trend {features.split(';')[0] if features else 'N/A'}
|
| 255 |
+
{holding_summary}
|
| 256 |
+
Should I {action.capitalize()}?** {should_action}
|
| 257 |
+
Best Date/Time to {action.capitalize()}**: {best_date}
|
| 258 |
+
Why? {why}
|
| 259 |
+
Prediction: {prediction}
|
| 260 |
+
Recommendation: Allocate {allocation:.0f}% based on analysis. Not financial advice.
|
| 261 |
+
"""
|
| 262 |
+
return report.strip()
|
| 263 |
+
|
| 264 |
+
def main():
|
| 265 |
+
symbol, date, action, horizon, holding_period = None, None, None, None, None
|
| 266 |
+
while not symbol:
|
| 267 |
+
user_input = input("Describe the stock (e.g., 'I want to buy Apple stock for today' or 'I purchased TCS.NS stock 1 month ago, now sell today'): ").strip()
|
| 268 |
+
symbol, date, action, horizon, holding_period = parse_query(user_input)
|
| 269 |
+
if not symbol:
|
| 270 |
+
print("No symbol detected. Try again.")
|
| 271 |
+
|
| 272 |
+
print(f"Analyzing: {symbol} on {date or 'recent'} (IST) for {action} ({horizon})" + (f", holding: {holding_period}" if holding_period else ""))
|
| 273 |
+
|
| 274 |
+
# Fetch data ONCE before the loop
|
| 275 |
+
hist = fetch_data(symbol, horizon)
|
| 276 |
+
|
| 277 |
+
state = TraderState(
|
| 278 |
+
input_symbol=symbol,
|
| 279 |
+
query_date=date,
|
| 280 |
+
action=action,
|
| 281 |
+
horizon=horizon,
|
| 282 |
+
holding_period=holding_period,
|
| 283 |
+
# Pre-load data
|
| 284 |
+
raw_data={'hist': hist},
|
| 285 |
+
claim=None,
|
| 286 |
+
skepticism=None,
|
| 287 |
+
confidence=50,
|
| 288 |
+
iterations=0,
|
| 289 |
+
stop=False,
|
| 290 |
+
alert_message=None
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
max_iterations = 5
|
| 294 |
+
for _ in range(max_iterations):
|
| 295 |
+
state = hunter_agent(state)
|
| 296 |
+
state = skeptic_agent(state)
|
| 297 |
+
state = calibrator_agent(state)
|
| 298 |
+
if state['stop']:
|
| 299 |
+
break
|
| 300 |
+
|
| 301 |
+
state['alert_message'] = generate_alert_message(state, symbol, action, horizon)
|
| 302 |
+
|
| 303 |
+
result = {
|
| 304 |
+
"symbol": symbol,
|
| 305 |
+
"date": date,
|
| 306 |
+
"action": action,
|
| 307 |
+
"horizon": horizon,
|
| 308 |
+
"holding_period": holding_period,
|
| 309 |
+
"alert_message": state['alert_message'],
|
| 310 |
+
"iterations": state['iterations'],
|
| 311 |
+
"confidence": state['confidence']
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
print("\n--- AI Trader Analyzer Result ---")
|
| 315 |
+
print(f"Symbol: {result['symbol']}")
|
| 316 |
+
print(f"Date: {result['date']} (IST)")
|
| 317 |
+
print(f"Action: {result['action']}")
|
| 318 |
+
print(f"Horizon: {result['horizon']}")
|
| 319 |
+
print(f"Holding: {result['holding_period'] or 'None'}")
|
| 320 |
+
print(f"Message:\n{result['alert_message']}")
|
| 321 |
+
print(f"Iterations: {result['iterations']}")
|
| 322 |
+
print(f"Confidence: {result['confidence']}%")
|
| 323 |
+
|
| 324 |
+
if __name__ == "__main__":
|
| 325 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai
|
| 2 |
+
langchain
|
| 3 |
+
langgraph
|
| 4 |
+
yfinance
|
| 5 |
+
requests
|
| 6 |
+
uvicorn
|
| 7 |
+
matplotlib
|
| 8 |
+
pydantic
|
| 9 |
+
beautifulsoup4
|
| 10 |
+
python-dotenv
|
| 11 |
+
numpy
|
| 12 |
+
pytz
|
| 13 |
+
weasyprint
|
| 14 |
+
taipy
|
| 15 |
+
fpdf2
|
| 16 |
+
streamlit
|
| 17 |
+
fastapi
|
state.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import TypedDict, Optional, List, Dict, Any
|
| 2 |
+
|
| 3 |
+
class TraderState(TypedDict):
|
| 4 |
+
input_symbol: str
|
| 5 |
+
query_date: Optional[str]
|
| 6 |
+
# 'buy' or 'sell'
|
| 7 |
+
action: str
|
| 8 |
+
# 'intraday', 'scalping', 'swing', 'momentum', 'long_term'
|
| 9 |
+
horizon: str
|
| 10 |
+
# "1 month ago", "2 month ago",etc.
|
| 11 |
+
holding_period: Optional[str]
|
| 12 |
+
raw_data: Optional[Dict[str, Any]]
|
| 13 |
+
claim: Optional[str]
|
| 14 |
+
skepticism: Optional[str]
|
| 15 |
+
confidence: int
|
| 16 |
+
iterations: int
|
| 17 |
+
stop: bool
|
| 18 |
+
alert_message: Optional[str]
|
style.css
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Make Comparison/Analysis subheaders white and bold */
|
| 2 |
+
h3 {
|
| 3 |
+
color: white !important;
|
| 4 |
+
font-size: 1.8rem !important;
|
| 5 |
+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 1);
|
| 6 |
+
margin-top: 30px !important;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/* Ensure stock names and "Analysis:" text stay white */
|
| 10 |
+
.stMarkdown h3, .stMarkdown b, .stMarkdown strong {
|
| 11 |
+
color: white !important;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* Your existing white-box result style stays for the report itself */
|
| 15 |
+
[data-testid="stText"] {
|
| 16 |
+
background-color: white !important;
|
| 17 |
+
color: black !important;
|
| 18 |
+
padding: 15px !important;
|
| 19 |
+
border-radius: 10px !important;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* Target the result alert blocks specifically */
|
| 23 |
+
[data-testid="stText"] {
|
| 24 |
+
background-color: white !important;
|
| 25 |
+
color: black !important;
|
| 26 |
+
padding: 15px !important;
|
| 27 |
+
border-radius: 10px !important;
|
| 28 |
+
border: 1px solid #ccc !important;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* Ensure the preformatted text inside the block is also black */
|
| 32 |
+
[data-testid="stText"] pre {
|
| 33 |
+
color: black !important;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.block-container {
|
| 37 |
+
padding-top: 1rem !important;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
h1 {
|
| 41 |
+
margin-top: -20px !important;
|
| 42 |
+
margin-bottom: 90px !important;
|
| 43 |
+
color: white !important;
|
| 44 |
+
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
| 45 |
+
text-align: center;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.stButton button:hover {
|
| 49 |
+
background-color: transparent !important;
|
| 50 |
+
color: white !important;
|
| 51 |
+
border: 1px solid white;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.block-container {
|
| 55 |
+
padding-top: 1rem !important;
|
| 56 |
+
padding-bottom: 0rem;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Specifically target the heading to remove any extra margin */
|
| 60 |
+
h1 {
|
| 61 |
+
margin-top: -20px !important;
|
| 62 |
+
padding-top: 0px !important;
|
| 63 |
+
margin-bottom: 90px !important;
|
| 64 |
+
color: white !important;
|
| 65 |
+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
|
| 66 |
+
text-align: center;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Ensures the header bar doesn't block your title */
|
| 70 |
+
header.stAppHeader {
|
| 71 |
+
background-color: transparent;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* Target all Streamlit buttons */
|
| 75 |
+
.stButton button {
|
| 76 |
+
background-color: #0e3558;
|
| 77 |
+
color: #FFFFFF;
|
| 78 |
+
border: 1px solid #FFFFFF;
|
| 79 |
+
border-radius: 25px;
|
| 80 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
.stButton button:hover {
|
| 85 |
+
background-color: transparent !important;
|
| 86 |
+
color: white !important;
|
| 87 |
+
border: 1px solid white;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
.stButton button:focus {
|
| 92 |
+
color: white !important;
|
| 93 |
+
}
|
| 94 |
+
/* Fix the Title (st.title) visibility */
|
| 95 |
+
h1 {
|
| 96 |
+
color: white !important;
|
| 97 |
+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
|
| 98 |
+
text-align: center;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* Fix the Label for the text input box */
|
| 102 |
+
.stTextInput label {
|
| 103 |
+
color: white !important;
|
| 104 |
+
font-size: 1.2rem;
|
| 105 |
+
font-weight: bold;
|
| 106 |
+
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.stTextInput input {
|
| 110 |
+
color: #000000; /* Keeping input text black for clarity against white box */
|
| 111 |
+
border-radius: 8px; /* Your 'br' fixing */
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Target the main container for a full-screen background */
|
| 115 |
+
|
| 116 |
+
[data-testid="stAppViewContainer"] {
|
| 117 |
+
background-image: url("data:image/jpg;base64,IMAGE_PLACEHOLDER");
|
| 118 |
+
background-size: cover;
|
| 119 |
+
background-position: center;
|
| 120 |
+
background-attachment: fixed;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* Fix your alignment and borders (br) here */
|
| 124 |
+
.main {
|
| 125 |
+
background-color: transparent;
|
| 126 |
+
padding: 2rem;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Centered container for your title */
|
| 130 |
+
.centered-title-container {
|
| 131 |
+
text-align: center;
|
| 132 |
+
border-radius: 12px;
|
| 133 |
+
background-color: rgba(0, 0, 0, 0.5);
|
| 134 |
+
padding: 20px;
|
| 135 |
+
margin-bottom: 25px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
[data-testid="stSidebar"] {
|
| 139 |
+
background-color: rgba(38, 39, 48, 0.9);
|
| 140 |
+
}
|