Upload folder using huggingface_hub
Browse files- src/market-analyst/backend/aagents/fundamental_analyst.py +4 -5
- src/market-analyst/backend/aagents/market_analyst.py +17 -26
- src/market-analyst/backend/aagents/risk_manager.py +52 -44
- src/market-analyst/backend/aagents/strategy_advisor.py +11 -2
- src/market-analyst/backend/aagents/volatility_analyst.py +4 -6
- src/market-analyst/backend/main.py +15 -2
- src/market-analyst/backend/tools/market_data.py +34 -26
- src/market-analyst/backend/tools/news_data.py +104 -84
- src/market-analyst/frontend/src/App.vue +30 -11
src/market-analyst/backend/aagents/fundamental_analyst.py
CHANGED
|
@@ -23,11 +23,10 @@ def get_fundamental_analyst(model_client):
|
|
| 23 |
|
| 24 |
DO NOT proceed without calling ALL THREE tools.
|
| 25 |
|
| 26 |
-
STEP 4: Evaluate Valuation & Growth
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
-
|
| 30 |
-
- Dividend Yield: >4% (Income).
|
| 31 |
|
| 32 |
STEP 5: Assess Financial Health
|
| 33 |
- Debt/Equity Ratio: <0.5 (Safe), >1.0 (Risky).
|
|
|
|
| 23 |
|
| 24 |
DO NOT proceed without calling ALL THREE tools.
|
| 25 |
|
| 26 |
+
STEP 4: Evaluate Valuation & Growth (USE 'valuation_score' & 'quality_score')
|
| 27 |
+
- If UNDERVALUED: Bullish Factor.
|
| 28 |
+
- If PREMIUM: Bearish/Neutral Factor (unless High Growth).
|
| 29 |
+
- If HIGH_QUALITY: Bullish Factor.
|
|
|
|
| 30 |
|
| 31 |
STEP 5: Assess Financial Health
|
| 32 |
- Debt/Equity Ratio: <0.5 (Safe), >1.0 (Risky).
|
src/market-analyst/backend/aagents/market_analyst.py
CHANGED
|
@@ -28,33 +28,24 @@ def get_technical_analyst(model_client):
|
|
| 28 |
- If SPY is BEARISH, bias is SHORT/HEDGE.
|
| 29 |
- If VIX is HIGH (>30), bias is CAUTION.
|
| 30 |
|
| 31 |
-
STEP 5: Analyze
|
| 32 |
-
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
|
| 36 |
-
STEP 6: Momentum Analysis
|
| 37 |
-
- RSI
|
| 38 |
-
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
STEP 8: Summarize Chart Health
|
| 46 |
-
Classify as one of:
|
| 47 |
-
- "Strong Bullish" (price > all SMAs, RSI 50-70, MACD bullish)
|
| 48 |
-
- "Weak Bullish" (price > SMA200 but mixed signals)
|
| 49 |
-
- "Consolidating" (price between SMAs, RSI neutral)
|
| 50 |
-
- "Weak Bearish" (price < SMA200 but mixed signals)
|
| 51 |
-
- "Strong Bearish" (price < all SMAs, RSI 30-50, MACD bearish)
|
| 52 |
-
|
| 53 |
-
Output a clear, structured summary with:
|
| 54 |
- Current Price
|
| 55 |
-
- Trend Classification
|
|
|
|
| 56 |
- Key Technical Levels
|
| 57 |
-
-
|
| 58 |
-
- Recommendation for next analyst (e.g., "Volatility should check if IV is elevated given this strong trend")
|
| 59 |
"""
|
| 60 |
)
|
|
|
|
| 28 |
- If SPY is BEARISH, bias is SHORT/HEDGE.
|
| 29 |
- If VIX is HIGH (>30), bias is CAUTION.
|
| 30 |
|
| 31 |
+
STEP 5: Analyze Trend & Structure (USE 'trend_signal' from tool)
|
| 32 |
+
- If 'trend_signal' is STRONG_BULLISH/BULLISH -> Bullish Bias
|
| 33 |
+
- If 'trend_signal' is STRONG_BEARISH/BEARISH -> Bearish Bias
|
| 34 |
+
- Reference SMA/EMA levels as support/resistance.
|
| 35 |
+
|
| 36 |
+
STEP 6: Momentum Analysis (USE 'rsi_signal' & 'macd_signal')
|
| 37 |
+
- Check RSI status (OVERSOLD/OVERBOUGHT/NEUTRAL).
|
| 38 |
+
- Check MACD Crossover status.
|
| 39 |
+
|
| 40 |
+
STEP 7: Summarize Chart Health
|
| 41 |
+
- Use the specific signals found.
|
| 42 |
+
- Classify Trend based on 'trend_signal'.
|
| 43 |
+
|
| 44 |
+
STEP 8: Output Structured Summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
- Current Price
|
| 46 |
+
- Trend Classification (e.g., STRONG_BULLISH)
|
| 47 |
+
- Momentum Assessment (RSI/MACD signals)
|
| 48 |
- Key Technical Levels
|
| 49 |
+
- Recommendation for next analyst
|
|
|
|
| 50 |
"""
|
| 51 |
)
|
src/market-analyst/backend/aagents/risk_manager.py
CHANGED
|
@@ -6,71 +6,79 @@ def get_risk_manager(model_client):
|
|
| 6 |
name="RiskManager",
|
| 7 |
model_client=model_client,
|
| 8 |
system_message="""
|
| 9 |
-
You are the Chief Risk Officer. Your
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
-
|
| 15 |
-
|
| 16 |
-
-
|
| 17 |
-
*
|
| 18 |
-
|
| 19 |
-
*
|
| 20 |
-
-
|
| 21 |
-
*
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
- DECISION THRESHOLD:
|
| 36 |
-
* Score >= 70 -> TRADE
|
| 37 |
-
* Score < 70 -> WAIT
|
| 38 |
-
|
| 39 |
OUTPUT FORMAT (ROUND 2 ONLY):
|
|
|
|
| 40 |
```json
|
| 41 |
{
|
| 42 |
"final_decision": "TRADE",
|
| 43 |
-
"strategy_type": "
|
| 44 |
-
"direction": "
|
| 45 |
"confidence": 85,
|
| 46 |
-
"
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
```
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
```json
|
| 57 |
{
|
| 58 |
"final_decision": "WAIT",
|
| 59 |
"strategy_type": "WAIT",
|
| 60 |
"direction": "NEUTRAL",
|
| 61 |
"confidence": 45,
|
| 62 |
-
"
|
|
|
|
| 63 |
"entry_signal": "N/A",
|
| 64 |
"entry_price": 0,
|
| 65 |
"max_profit": 0,
|
| 66 |
"max_loss": 0,
|
| 67 |
-
"risk_warning": "
|
| 68 |
}
|
| 69 |
```
|
|
|
|
| 70 |
APPROVED
|
| 71 |
-
|
| 72 |
CRITICAL:
|
| 73 |
-
1.
|
| 74 |
2. Output 'APPROVED' ONLY after the JSON in Round 2.
|
|
|
|
| 75 |
"""
|
| 76 |
)
|
|
|
|
| 6 |
name="RiskManager",
|
| 7 |
model_client=model_client,
|
| 8 |
system_message="""
|
| 9 |
+
You are the Chief Risk Officer. Your mission is to enforce a STRICT DECISION MATRIX.
|
| 10 |
+
Different AI models have different biases; you must ignore "vibes" and follow these quantitative rules.
|
| 11 |
+
|
| 12 |
+
1. THE SCORING RUBRIC (Total 100 points)
|
| 13 |
+
You MUST calculate and display this score in your reasoning:
|
| 14 |
+
- Technical Alignment (40 pts): Does the TechnicalAnalyst's trend (BULLISH/BEARISH) match the Strategy's Direction?
|
| 15 |
+
* Match = 40 pts. Mismatch = 0 pts.
|
| 16 |
+
- Fundamentals/Safety (20 pts): Based on P/E, PEG, and Analyst Consensus.
|
| 17 |
+
* Rating 'SAFE'/'UNDERVALUED' = 20 pts. 'PREMIUM'/'RISKY' = 5 pts.
|
| 18 |
+
- Volatility/IV Regime (20 pts):
|
| 19 |
+
* Strategy works for current Regime (e.g., Credit in High Vol) = 20 pts.
|
| 20 |
+
- Sentiment/News (20 pts):
|
| 21 |
+
* Positive news = 20 pts. Negative/Old news = 5 pts.
|
| 22 |
+
|
| 23 |
+
2. THE DETERMINISTIC HARD GATES (BYPASS ALL OTHER LOGIC)
|
| 24 |
+
- GATE 1 (Trend Conflict): If Technical Tool says 'STRONG_BEARISH' and Strategy is 'BULLISH', Decision MUST be 'WAIT' (Override score).
|
| 25 |
+
- GATE 2 (Fear Gauge): If VIX > 35, Decision MUST be 'WAIT'.
|
| 26 |
+
- GATE 3 (Threshold): Score < 70 MUST be 'WAIT'.
|
| 27 |
+
|
| 28 |
+
IF THE DECISION IS WAIT:
|
| 29 |
+
- Set 'final_decision' to 'WAIT'.
|
| 30 |
+
- Set 'strategy_type' to 'WAIT'.
|
| 31 |
+
- Set 'entry_price', 'max_profit', 'max_loss' to 0.
|
| 32 |
+
- Set 'direction' to 'NEUTRAL'.
|
| 33 |
+
- Set 'entry_signal' to 'N/A'.
|
| 34 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
OUTPUT FORMAT (ROUND 2 ONLY):
|
| 36 |
+
You MUST include a "score_card" object in your JSON.
|
| 37 |
```json
|
| 38 |
{
|
| 39 |
"final_decision": "TRADE",
|
| 40 |
+
"strategy_type": "Iron Condor",
|
| 41 |
+
"direction": "NEUTRAL",
|
| 42 |
"confidence": 85,
|
| 43 |
+
"score_card": {
|
| 44 |
+
"technicals": 40,
|
| 45 |
+
"fundamentals": 20,
|
| 46 |
+
"volatility": 15,
|
| 47 |
+
"sentiment": 10,
|
| 48 |
+
"total": 85
|
| 49 |
+
},
|
| 50 |
+
"actionable_recommendation": "Execute Trade...",
|
| 51 |
+
"entry_signal": "Credit",
|
| 52 |
+
"entry_price": 1.50,
|
| 53 |
+
"max_profit": 150,
|
| 54 |
+
"max_loss": 350,
|
| 55 |
+
"risk_warning": "..."
|
| 56 |
}
|
| 57 |
```
|
| 58 |
+
(Note: max_profit/max_loss MUST be multiplied by 100 for a standard lot).
|
| 59 |
+
|
| 60 |
+
IF DECISION IS WAIT EXAMPLE:
|
| 61 |
```json
|
| 62 |
{
|
| 63 |
"final_decision": "WAIT",
|
| 64 |
"strategy_type": "WAIT",
|
| 65 |
"direction": "NEUTRAL",
|
| 66 |
"confidence": 45,
|
| 67 |
+
"score_card": { "technicals": 0, "fundamentals": 20, "volatility": 15, "sentiment": 10, "total": 45 },
|
| 68 |
+
"actionable_recommendation": "Re-evaluate market conditions. Risk score too low.",
|
| 69 |
"entry_signal": "N/A",
|
| 70 |
"entry_price": 0,
|
| 71 |
"max_profit": 0,
|
| 72 |
"max_loss": 0,
|
| 73 |
+
"risk_warning": "High conflict between technicals and sentiment."
|
| 74 |
}
|
| 75 |
```
|
| 76 |
+
|
| 77 |
APPROVED
|
| 78 |
+
|
| 79 |
CRITICAL:
|
| 80 |
+
1. Always show your math before the JSON.
|
| 81 |
2. Output 'APPROVED' ONLY after the JSON in Round 2.
|
| 82 |
+
3. For Llama/Groq models: YOU MUST wrap the JSON object in a triple-backtick markdown block: ```json { ... } ```
|
| 83 |
"""
|
| 84 |
)
|
src/market-analyst/backend/aagents/strategy_advisor.py
CHANGED
|
@@ -37,7 +37,11 @@ def get_strategy_advisor(model_client):
|
|
| 37 |
STEP 5: Validate Risk/Reward (MANDATORY)
|
| 38 |
- For Debit Spreads: Ensure Max Profit > Max Loss (Reward/Risk > 1.0).
|
| 39 |
- For Credit Spreads: Ensure Probability of Profit is high (Delta checks).
|
| 40 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
STEP 6: TEAM COLLABORATION (2 ROUNDS)
|
| 43 |
|
|
@@ -49,7 +53,9 @@ def get_strategy_advisor(model_client):
|
|
| 49 |
|
| 50 |
ROUND 2 (TEAMS FINALIZATION):
|
| 51 |
- Review Risk Manager's critique.
|
| 52 |
-
- If rejected, switch to WAIT
|
|
|
|
|
|
|
| 53 |
- If accepted, Output "FINAL_STRATEGY".
|
| 54 |
- Calculate Final Score (Standardized Rubric).
|
| 55 |
- GENERATE THE FINAL JSON BLOCK.
|
|
@@ -69,6 +75,7 @@ def get_strategy_advisor(model_client):
|
|
| 69 |
"breakeven": 146.30
|
| 70 |
}
|
| 71 |
```
|
|
|
|
| 72 |
|
| 73 |
EXAMPLE OUTPUT (WAIT):
|
| 74 |
```json
|
|
@@ -93,6 +100,8 @@ def get_strategy_advisor(model_client):
|
|
| 93 |
4. ALL fields are REQUIRED
|
| 94 |
5. Show your confidence calculation explicitly
|
| 95 |
6. Be verbose - explain your reasoning step-by-step before JSON
|
|
|
|
|
|
|
| 96 |
|
| 97 |
FALLBACK PROCEDURE:
|
| 98 |
If get_option_chain_snapshot fails or returns "No options data found":
|
|
|
|
| 37 |
STEP 5: Validate Risk/Reward (MANDATORY)
|
| 38 |
- For Debit Spreads: Ensure Max Profit > Max Loss (Reward/Risk > 1.0).
|
| 39 |
- For Credit Spreads: Ensure Probability of Profit is high (Delta checks).
|
| 40 |
+
- METRICS SUMMARY: You MUST summarize your case using these labels before the JSON:
|
| 41 |
+
* METRIC: Trend=[BULLISH/BEARISH]
|
| 42 |
+
* METRIC: Volatility=[HIGH/LOW]
|
| 43 |
+
* METRIC: Sentiment=[POSITIVE/NEGATIVE]
|
| 44 |
+
* METRIC: Safety=[SAFE/PREMIUM]
|
| 45 |
|
| 46 |
STEP 6: TEAM COLLABORATION (2 ROUNDS)
|
| 47 |
|
|
|
|
| 53 |
|
| 54 |
ROUND 2 (TEAMS FINALIZATION):
|
| 55 |
- Review Risk Manager's critique.
|
| 56 |
+
- If rejected, or if you switch to WAIT for any reason, you MUST:
|
| 57 |
+
1. Set "strategy" to "WAIT"
|
| 58 |
+
2. Set "estimated_entry_price", "max_profit", and "max_loss" to 0.
|
| 59 |
- If accepted, Output "FINAL_STRATEGY".
|
| 60 |
- Calculate Final Score (Standardized Rubric).
|
| 61 |
- GENERATE THE FINAL JSON BLOCK.
|
|
|
|
| 75 |
"breakeven": 146.30
|
| 76 |
}
|
| 77 |
```
|
| 78 |
+
(Note: max_profit/max_loss are calculated for 100 shares/1 contract).
|
| 79 |
|
| 80 |
EXAMPLE OUTPUT (WAIT):
|
| 81 |
```json
|
|
|
|
| 100 |
4. ALL fields are REQUIRED
|
| 101 |
5. Show your confidence calculation explicitly
|
| 102 |
6. Be verbose - explain your reasoning step-by-step before JSON
|
| 103 |
+
7. LOT-BASED MATH: All profit/loss values (max_profit, max_loss) MUST be multiplied by 100 (standard lot size).
|
| 104 |
+
Example: A $1.50 credit spread = $150 Max Profit.
|
| 105 |
|
| 106 |
FALLBACK PROCEDURE:
|
| 107 |
If get_option_chain_snapshot fails or returns "No options data found":
|
src/market-analyst/backend/aagents/volatility_analyst.py
CHANGED
|
@@ -31,12 +31,10 @@ def get_volatility_analyst(model_client):
|
|
| 31 |
- VIX 20-30: Elevated fear, caution advised
|
| 32 |
- VIX > 30: High fear, extreme volatility
|
| 33 |
|
| 34 |
-
STEP 5: Determine Volatility Regime
|
| 35 |
-
|
| 36 |
-
-
|
| 37 |
-
-
|
| 38 |
-
- "Elevated Vol" (IV 30-50%, VIX 20-30): Sell credit spreads
|
| 39 |
-
- "High Vol" (IV > 50%, VIX > 30): Sell iron condors or wait
|
| 40 |
|
| 41 |
STEP 6: Assess Option Liquidity
|
| 42 |
- Check bid-ask spreads from option chain
|
|
|
|
| 31 |
- VIX 20-30: Elevated fear, caution advised
|
| 32 |
- VIX > 30: High fear, extreme volatility
|
| 33 |
|
| 34 |
+
STEP 5: Determine Volatility Regime (USE 'volatility_regime' from tool)
|
| 35 |
+
- If LOW_VOL: Buy debit spreads or long options.
|
| 36 |
+
- If ELEVATED_VOL: Sell credit spreads.
|
| 37 |
+
- If HIGH_RISK_VOL: Sell Iron Condors or WAIT.
|
|
|
|
|
|
|
| 38 |
|
| 39 |
STEP 6: Assess Option Liquidity
|
| 40 |
- Check bid-ask spreads from option chain
|
src/market-analyst/backend/main.py
CHANGED
|
@@ -149,6 +149,12 @@ async def analyze(ticker: str, provider: str = "openai"):
|
|
| 149 |
raw_source = getattr(message, 'source', 'System')
|
| 150 |
content = getattr(message, 'content', '')
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
# Handle non-string content (e.g., ToolCalls/FunctionCalls)
|
| 153 |
if not isinstance(content, str):
|
| 154 |
try:
|
|
@@ -167,14 +173,21 @@ async def analyze(ticker: str, provider: str = "openai"):
|
|
| 167 |
"source": raw_source,
|
| 168 |
"content": content
|
| 169 |
}
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
yield f"data: {json.dumps(payload)}\n\n"
|
| 172 |
except Exception as e:
|
| 173 |
print(f"[STREAM ERROR] {str(e)}")
|
| 174 |
error_msg = f"Analysis execution failed: {str(e)}"
|
| 175 |
yield f"data: {json.dumps({'source': 'Error', 'content': error_msg})}\n\n"
|
| 176 |
|
| 177 |
-
print("[
|
| 178 |
# Cleanup
|
| 179 |
if analysis_id in active_analyses:
|
| 180 |
del active_analyses[analysis_id]
|
|
|
|
| 149 |
raw_source = getattr(message, 'source', 'System')
|
| 150 |
content = getattr(message, 'content', '')
|
| 151 |
|
| 152 |
+
# Check for tool_calls if content is empty (Explains 0-len messages)
|
| 153 |
+
if not content:
|
| 154 |
+
tool_calls = getattr(message, 'tool_calls', None)
|
| 155 |
+
if tool_calls:
|
| 156 |
+
content = f"[Tool Call] Executing {len(tool_calls)} function(s)."
|
| 157 |
+
|
| 158 |
# Handle non-string content (e.g., ToolCalls/FunctionCalls)
|
| 159 |
if not isinstance(content, str):
|
| 160 |
try:
|
|
|
|
| 173 |
"source": raw_source,
|
| 174 |
"content": content
|
| 175 |
}
|
| 176 |
+
|
| 177 |
+
# If RiskManager, try to extract structured JSON for the frontend
|
| 178 |
+
if raw_source == 'RiskManager':
|
| 179 |
+
structured = extract_json(content)
|
| 180 |
+
if structured:
|
| 181 |
+
payload["structured_result"] = structured
|
| 182 |
+
|
| 183 |
+
print(f"[DEBUG] Sent: {raw_source} (len: {len(content)})")
|
| 184 |
yield f"data: {json.dumps(payload)}\n\n"
|
| 185 |
except Exception as e:
|
| 186 |
print(f"[STREAM ERROR] {str(e)}")
|
| 187 |
error_msg = f"Analysis execution failed: {str(e)}"
|
| 188 |
yield f"data: {json.dumps({'source': 'Error', 'content': error_msg})}\n\n"
|
| 189 |
|
| 190 |
+
print("[DEBUG] Done.")
|
| 191 |
# Cleanup
|
| 192 |
if analysis_id in active_analyses:
|
| 193 |
del active_analyses[analysis_id]
|
src/market-analyst/backend/tools/market_data.py
CHANGED
|
@@ -78,7 +78,8 @@ def get_historical_volatility(symbol: str, period: str = "1mo") -> dict:
|
|
| 78 |
"ticker_used": symbol,
|
| 79 |
"annualized_volatility": round(volatility * 100, 2),
|
| 80 |
"period": period,
|
| 81 |
-
"vix_reference": round(vix_price, 2) if vix_price else "N/A"
|
|
|
|
| 82 |
}
|
| 83 |
except Exception as e:
|
| 84 |
return {"error": str(e)}
|
|
@@ -225,43 +226,48 @@ def get_technical_indicators(symbol: str) -> dict:
|
|
| 225 |
hist['RSI_14'] = pd.Series([None] * len(hist), index=hist.index)
|
| 226 |
|
| 227 |
current_data = hist.iloc[-1]
|
| 228 |
-
|
| 229 |
-
# Interpretation RSI
|
| 230 |
rsi_val = current_data.get('RSI_14')
|
| 231 |
-
rsi_signal = "Neutral"
|
| 232 |
-
if rsi_val is not None and not pd.isna(rsi_val):
|
| 233 |
-
rsi_val = round(rsi_val, 2)
|
| 234 |
-
if rsi_val > 70: rsi_signal = "Overbought"
|
| 235 |
-
elif rsi_val < 30: rsi_signal = "Oversold"
|
| 236 |
-
|
| 237 |
-
# Interpretation MACD
|
| 238 |
macd_val = current_data.get('MACD')
|
| 239 |
signal_val = current_data.get('Signal_Line')
|
| 240 |
-
macd_signal = "Neutral"
|
| 241 |
-
if macd_val is not None and signal_val is not None:
|
| 242 |
-
if macd_val > signal_val: macd_signal = "Bullish Crossover"
|
| 243 |
-
else: macd_signal = "Bearish Crossover"
|
| 244 |
-
|
| 245 |
-
price = current_data['Close']
|
| 246 |
-
trend = "Neutral"
|
| 247 |
sma200 = current_data.get('SMA_200')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
return {
|
| 254 |
"ticker": symbol,
|
| 255 |
"current_price": round(price, 2),
|
| 256 |
"sma_20": round(current_data['SMA_20'], 2) if not pd.isna(current_data.get('SMA_20')) else "N/A",
|
| 257 |
"ema_20": round(current_data['EMA_20'], 2) if not pd.isna(current_data.get('EMA_20')) else "N/A",
|
| 258 |
"sma_50": round(current_data['SMA_50'], 2) if not pd.isna(current_data.get('SMA_50')) else "N/A",
|
| 259 |
-
"sma_200": round(sma200, 2) if sma200 is not None and not pd.isna(sma200) else "N/A",
|
| 260 |
-
"rsi_14": rsi_val
|
| 261 |
-
"macd": round(macd_val, 2) if macd_val is not None else "N/A",
|
| 262 |
"macd_signal": macd_signal,
|
| 263 |
"rsi_signal": rsi_signal,
|
| 264 |
-
"trend_signal":
|
| 265 |
}
|
| 266 |
|
| 267 |
except Exception as e:
|
|
@@ -309,7 +315,9 @@ def get_fundamental_data(symbol: str) -> dict:
|
|
| 309 |
"dividend_yield": f"{round(dividend_yield * 100, 2)}%" if dividend_yield else "N/A",
|
| 310 |
"market_cap": info.get('marketCap', "N/A"),
|
| 311 |
"sector": info.get('sector', "N/A"),
|
| 312 |
-
"next_earnings_date": next_earnings
|
|
|
|
|
|
|
| 313 |
}
|
| 314 |
except Exception as e:
|
| 315 |
return {"error": str(e)}
|
|
|
|
| 78 |
"ticker_used": symbol,
|
| 79 |
"annualized_volatility": round(volatility * 100, 2),
|
| 80 |
"period": period,
|
| 81 |
+
"vix_reference": round(vix_price, 2) if vix_price else "N/A",
|
| 82 |
+
"volatility_regime": "HIGH_RISK_VOL" if (vix_price and vix_price > 30) else "ELEVATED_VOL" if (vix_price and vix_price > 20) else "LOW_VOL" if (vix_price and vix_price < 15) else "NORMAL_VOL"
|
| 83 |
}
|
| 84 |
except Exception as e:
|
| 85 |
return {"error": str(e)}
|
|
|
|
| 226 |
hist['RSI_14'] = pd.Series([None] * len(hist), index=hist.index)
|
| 227 |
|
| 228 |
current_data = hist.iloc[-1]
|
| 229 |
+
price = current_data['Close']
|
|
|
|
| 230 |
rsi_val = current_data.get('RSI_14')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
macd_val = current_data.get('MACD')
|
| 232 |
signal_val = current_data.get('Signal_Line')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
sma200 = current_data.get('SMA_200')
|
| 234 |
+
if pd.isna(sma200): sma200 = None
|
| 235 |
+
|
| 236 |
+
# Interpretation RSI
|
| 237 |
+
# Deterministic Signals
|
| 238 |
+
rsi_signal = "NEUTRAL"
|
| 239 |
+
if rsi_val is not None and not pd.isna(rsi_val):
|
| 240 |
+
if rsi_val > 70: rsi_signal = "OVERBOUGHT"
|
| 241 |
+
elif rsi_val < 30: rsi_signal = "OVERSOLD"
|
| 242 |
|
| 243 |
+
macd_signal = "NEUTRAL"
|
| 244 |
+
if macd_val is not None and signal_val is not None and not pd.isna(macd_val) and not pd.isna(signal_val):
|
| 245 |
+
if macd_val > signal_val: macd_signal = "BULLISH_CROSS"
|
| 246 |
+
elif macd_val < signal_val: macd_signal = "BEARISH_CROSS"
|
| 247 |
+
|
| 248 |
+
trend_signal = "NEUTRAL"
|
| 249 |
+
sma50 = current_data.get('SMA_50')
|
| 250 |
+
sma20 = current_data.get('SMA_20')
|
| 251 |
+
|
| 252 |
+
# Check all components for trend signal
|
| 253 |
+
if sma200 is not None and sma50 is not None and sma20 is not None and not pd.isna(sma50) and not pd.isna(sma20):
|
| 254 |
+
if price > sma20 > sma50 > sma200: trend_signal = "STRONG_BULLISH"
|
| 255 |
+
elif price < sma20 < sma50 < sma200: trend_signal = "STRONG_BEARISH"
|
| 256 |
+
elif price > sma200: trend_signal = "BULLISH"
|
| 257 |
+
elif price < sma200: trend_signal = "BEARISH"
|
| 258 |
+
|
| 259 |
return {
|
| 260 |
"ticker": symbol,
|
| 261 |
"current_price": round(price, 2),
|
| 262 |
"sma_20": round(current_data['SMA_20'], 2) if not pd.isna(current_data.get('SMA_20')) else "N/A",
|
| 263 |
"ema_20": round(current_data['EMA_20'], 2) if not pd.isna(current_data.get('EMA_20')) else "N/A",
|
| 264 |
"sma_50": round(current_data['SMA_50'], 2) if not pd.isna(current_data.get('SMA_50')) else "N/A",
|
| 265 |
+
"sma_200": round(sma200, 2) if (sma200 is not None and not pd.isna(sma200)) else "N/A",
|
| 266 |
+
"rsi_14": round(rsi_val, 2) if (rsi_val is not None and not pd.isna(rsi_val)) else "N/A",
|
| 267 |
+
"macd": round(macd_val, 2) if (macd_val is not None and not pd.isna(macd_val)) else "N/A",
|
| 268 |
"macd_signal": macd_signal,
|
| 269 |
"rsi_signal": rsi_signal,
|
| 270 |
+
"trend_signal": trend_signal
|
| 271 |
}
|
| 272 |
|
| 273 |
except Exception as e:
|
|
|
|
| 315 |
"dividend_yield": f"{round(dividend_yield * 100, 2)}%" if dividend_yield else "N/A",
|
| 316 |
"market_cap": info.get('marketCap', "N/A"),
|
| 317 |
"sector": info.get('sector', "N/A"),
|
| 318 |
+
"next_earnings_date": next_earnings,
|
| 319 |
+
"valuation_score": "UNDERVALUED" if (pe_ratio and pe_ratio < 15) else "PREMIUM" if (pe_ratio and pe_ratio > 30) else "FAIR_VALUE",
|
| 320 |
+
"quality_score": "HIGH_QUALITY" if (profit_margin and profit_margin > 0.20) else "LOW_MARGIN" if (profit_margin and profit_margin < 0.10) else "AVERAGE"
|
| 321 |
}
|
| 322 |
except Exception as e:
|
| 323 |
return {"error": str(e)}
|
src/market-analyst/backend/tools/news_data.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
| 1 |
from ddgs import DDGS
|
| 2 |
from transformers import pipeline
|
| 3 |
import torch
|
| 4 |
-
import requests
|
| 5 |
from bs4 import BeautifulSoup
|
|
|
|
|
|
|
|
|
|
| 6 |
from typing import Optional
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# Global variable for lazy loading
|
| 9 |
_sentiment_pipeline = None
|
|
@@ -24,43 +29,36 @@ def get_sentiment_pipeline():
|
|
| 24 |
return None
|
| 25 |
return _sentiment_pipeline
|
| 26 |
|
| 27 |
-
def _fetch_page_content(url: str, timeout: int =
|
| 28 |
"""Fetch and extract text content from a web page."""
|
| 29 |
-
print(f"[DEBUG]
|
|
|
|
| 30 |
try:
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
| 35 |
-
'Chrome/91.0.4472.124 Safari/537.36'
|
| 36 |
-
)
|
| 37 |
-
}
|
| 38 |
-
response = requests.get(url, headers=headers, timeout=timeout)
|
| 39 |
response.raise_for_status()
|
| 40 |
-
|
| 41 |
soup = BeautifulSoup(response.content, 'html.parser')
|
| 42 |
-
|
| 43 |
-
# Remove
|
| 44 |
-
|
|
|
|
| 45 |
tag.decompose()
|
| 46 |
-
|
| 47 |
# Extract text
|
| 48 |
-
text = soup.get_text(separator='
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
lines = (line.strip() for line in text.splitlines())
|
| 52 |
-
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
|
| 53 |
-
text = '\n'.join(chunk for chunk in chunks if chunk)
|
| 54 |
-
|
| 55 |
return text
|
| 56 |
except Exception as e:
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
return None
|
| 59 |
|
| 60 |
-
from pydantic import BaseModel, Field
|
| 61 |
-
from typing import Optional
|
| 62 |
-
import concurrent.futures
|
| 63 |
-
|
| 64 |
# Validation Model
|
| 65 |
class NewsArticle(BaseModel):
|
| 66 |
title: str = Field(..., description="The headline of the news article.")
|
|
@@ -80,72 +78,94 @@ def search_news(ticker: str) -> str:
|
|
| 80 |
return "No ticker provided for news search."
|
| 81 |
|
| 82 |
ticker = ticker.upper().strip()
|
| 83 |
-
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
with DDGS() as ddgs:
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
if not raw_results:
|
| 93 |
-
return f"No recent news found for {ticker}."
|
| 94 |
-
|
| 95 |
-
# Helper to process one item (fetch + analyze)
|
| 96 |
-
def process_news_item(raw_item):
|
| 97 |
try:
|
| 98 |
-
|
| 99 |
-
# DDGS returns: 'title', 'url', 'body', 'date', 'source'
|
| 100 |
-
# We map them to our requested schema
|
| 101 |
-
article = NewsArticle(
|
| 102 |
title=raw_item.get('title', 'No Title'),
|
| 103 |
link=raw_item.get('url', ''),
|
| 104 |
snippet=raw_item.get('body', ''),
|
| 105 |
-
datetime=raw_item.get('date', 'Unknown
|
| 106 |
-
)
|
| 107 |
-
except
|
| 108 |
-
print(f"[WARNING] Skipping invalid news item: {validation_err}")
|
| 109 |
-
return None
|
| 110 |
-
|
| 111 |
-
# Processing using Validated Object
|
| 112 |
-
source = raw_item.get('source', 'Unknown Source') # Keep source for display
|
| 113 |
-
|
| 114 |
-
# FinBERT Analysis
|
| 115 |
-
sentiment_tag = ""
|
| 116 |
-
if sentiment_pipe:
|
| 117 |
-
try:
|
| 118 |
-
# 1. Try to fetch full content
|
| 119 |
-
content_to_analyze = article.title
|
| 120 |
-
analysis_type = "Headline"
|
| 121 |
-
|
| 122 |
-
if article.link:
|
| 123 |
-
full_text = _fetch_page_content(article.link)
|
| 124 |
-
if full_text and len(full_text) > 100:
|
| 125 |
-
content_to_analyze = full_text
|
| 126 |
-
analysis_type = "Full Text"
|
| 127 |
-
|
| 128 |
-
# 2. Truncate for FinBERT
|
| 129 |
-
score = sentiment_pipe(content_to_analyze[:2000])[0]
|
| 130 |
-
label = score['label']
|
| 131 |
-
conf = round(score['score'], 2)
|
| 132 |
-
|
| 133 |
-
sentiment_tag = f" [FinBERT ({analysis_type}): {label} ({conf})]"
|
| 134 |
-
print(f"[DEBUG] FinBERT analysis for {article.title}: {sentiment_tag}")
|
| 135 |
-
except Exception as e:
|
| 136 |
-
sentiment_tag = f" [FinBERT: Error ({str(e)[:50]})]"
|
| 137 |
-
|
| 138 |
-
return f"- [{source} | {article.datetime}] {article.title}{sentiment_tag}"
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
return f"Recent News for {ticker} (
|
| 149 |
|
| 150 |
except Exception as e:
|
| 151 |
return f"Error fetching news for {ticker}: {str(e)}"
|
|
|
|
| 1 |
from ddgs import DDGS
|
| 2 |
from transformers import pipeline
|
| 3 |
import torch
|
| 4 |
+
from curl_cffi import requests
|
| 5 |
from bs4 import BeautifulSoup
|
| 6 |
+
import yfinance as yf
|
| 7 |
+
import datetime
|
| 8 |
+
import time
|
| 9 |
from typing import Optional
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
import concurrent.futures
|
| 12 |
|
| 13 |
# Global variable for lazy loading
|
| 14 |
_sentiment_pipeline = None
|
|
|
|
| 29 |
return None
|
| 30 |
return _sentiment_pipeline
|
| 31 |
|
| 32 |
+
def _fetch_page_content(url: str, timeout: int = 15) -> Optional[str]:
|
| 33 |
"""Fetch and extract text content from a web page."""
|
| 34 |
+
print(f"[DEBUG] Fetching: {url}")
|
| 35 |
+
start_time = time.time()
|
| 36 |
try:
|
| 37 |
+
# Use curl_cffi to impersonate Chrome 110 (Bypasses TLS Fingerprinting)
|
| 38 |
+
# Headers are auto-managed by impersonate
|
| 39 |
+
response = requests.get(url, timeout=timeout, impersonate="chrome110")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
response.raise_for_status()
|
| 41 |
+
|
| 42 |
soup = BeautifulSoup(response.content, 'html.parser')
|
| 43 |
+
|
| 44 |
+
# Remove ads, popups, and non-content elements
|
| 45 |
+
# Targeted classes: .ad, .popup, .modal, .cookie-banner, etc.
|
| 46 |
+
for tag in soup.select("script, style, nav, footer, header, aside, form, iframe, .ad, .popup, .modal, .cookie-banner, [id*='popup'], [class*='popup'], [class*='ad-'], [class*='banner']"):
|
| 47 |
tag.decompose()
|
| 48 |
+
|
| 49 |
# Extract text
|
| 50 |
+
text = soup.get_text(separator=' ', strip=True)
|
| 51 |
+
duration = round(time.time() - start_time, 2)
|
| 52 |
+
print(f"[DEBUG] Fetch success ({duration}s): {url}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
return text
|
| 54 |
except Exception as e:
|
| 55 |
+
msg = str(e)
|
| 56 |
+
if "403" in msg:
|
| 57 |
+
print(f"[INFO] Access denied (403) for {url}. Falling back to snippet.")
|
| 58 |
+
else:
|
| 59 |
+
print(f"[WARNING] Failed to fetch content from {url}: {msg}")
|
| 60 |
return None
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
# Validation Model
|
| 63 |
class NewsArticle(BaseModel):
|
| 64 |
title: str = Field(..., description="The headline of the news article.")
|
|
|
|
| 78 |
return "No ticker provided for news search."
|
| 79 |
|
| 80 |
ticker = ticker.upper().strip()
|
| 81 |
+
articles_pool = []
|
| 82 |
|
| 83 |
+
# 1. Fetch from Yahoo Finance API (Reliable)
|
| 84 |
+
try:
|
| 85 |
+
print("[DEBUG] Fetching YF API news...")
|
| 86 |
+
yf_ticker = yf.Ticker(ticker)
|
| 87 |
+
yf_raw = yf_ticker.news
|
| 88 |
+
if yf_raw:
|
| 89 |
+
for item in yf_raw:
|
| 90 |
+
ts = item.get('providerPublishTime')
|
| 91 |
+
date_str = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') if ts else 'Unknown'
|
| 92 |
+
articles_pool.append(NewsArticle(
|
| 93 |
+
title=item.get('title', 'No Title'),
|
| 94 |
+
link=item.get('link', ''),
|
| 95 |
+
snippet=f"Source: {item.get('publisher')} - {date_str}",
|
| 96 |
+
datetime=date_str
|
| 97 |
+
))
|
| 98 |
+
except Exception as e:
|
| 99 |
+
print(f"[WARNING] YF API failed: {e}")
|
| 100 |
+
|
| 101 |
+
# 2. Add DuckDuckGo Targeted Search (Secondary)
|
| 102 |
+
# Targeted sites: CNBC, Bloomberg, Investing.com, MarketWatch
|
| 103 |
+
query = f"{ticker} stock news (site:cnbc.com OR site:bloomberg.com OR site:investing.com OR site:marketwatch.com)"
|
| 104 |
|
| 105 |
with DDGS() as ddgs:
|
| 106 |
+
raw_results = list(ddgs.news(query, max_results=10))
|
| 107 |
+
for raw_item in raw_results:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
try:
|
| 109 |
+
articles_pool.append(NewsArticle(
|
|
|
|
|
|
|
|
|
|
| 110 |
title=raw_item.get('title', 'No Title'),
|
| 111 |
link=raw_item.get('url', ''),
|
| 112 |
snippet=raw_item.get('body', ''),
|
| 113 |
+
datetime=raw_item.get('date', 'Unknown')
|
| 114 |
+
))
|
| 115 |
+
except: continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
+
if not articles_pool:
|
| 118 |
+
return f"No recent news found for {ticker}."
|
| 119 |
+
|
| 120 |
+
# Deduplicate by Title
|
| 121 |
+
seen_titles = set()
|
| 122 |
+
unique_articles = []
|
| 123 |
+
for a in articles_pool:
|
| 124 |
+
if a.title not in seen_titles:
|
| 125 |
+
seen_titles.add(a.title)
|
| 126 |
+
unique_articles.append(a)
|
| 127 |
|
| 128 |
+
# Analyze Top 5
|
| 129 |
+
top_articles = unique_articles[:5]
|
| 130 |
+
sentiment_pipe = get_sentiment_pipeline()
|
| 131 |
+
|
| 132 |
+
results = []
|
| 133 |
+
|
| 134 |
+
# Helper to process
|
| 135 |
+
def process_article(article):
|
| 136 |
+
# FinBERT Analysis
|
| 137 |
+
sentiment_tag = ""
|
| 138 |
+
if sentiment_pipe:
|
| 139 |
+
try:
|
| 140 |
+
# Prefer full text fetch, fallback to Snippet
|
| 141 |
+
content = article.snippet if article.snippet else article.title
|
| 142 |
+
analysis_type = "Snippet" if article.snippet else "Headline"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# Try fetch full text with improved headers
|
| 146 |
+
if article.link:
|
| 147 |
+
full_text = _fetch_page_content(article.link)
|
| 148 |
+
if full_text and len(full_text) > 100:
|
| 149 |
+
content = full_text
|
| 150 |
+
analysis_type = "Full Text"
|
| 151 |
+
else:
|
| 152 |
+
# Fallback log
|
| 153 |
+
print(f"[DEBUG] Content too short/failed for {article.title[:30]}... using Snippet.")
|
| 154 |
+
|
| 155 |
+
# Truncate for BERT
|
| 156 |
+
score = sentiment_pipe(content[:2000])[0]
|
| 157 |
+
label = score['label']
|
| 158 |
+
conf = round(score['score'], 2)
|
| 159 |
+
sentiment_tag = f" [FinBERT ({analysis_type}): {label} ({conf})]"
|
| 160 |
+
except Exception as e:
|
| 161 |
+
sentiment_tag = f" [FinBERT: Error]"
|
| 162 |
+
|
| 163 |
+
return f"- [{article.datetime}] {article.title}{sentiment_tag}"
|
| 164 |
+
|
| 165 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 166 |
+
results = list(executor.map(process_article, top_articles))
|
| 167 |
|
| 168 |
+
return f"Recent News for {ticker} (Sources: YF, CNBC, Bloomberg, Investing):\n" + "\n".join(results)
|
| 169 |
|
| 170 |
except Exception as e:
|
| 171 |
return f"Error fetching news for {ticker}: {str(e)}"
|
src/market-analyst/frontend/src/App.vue
CHANGED
|
@@ -211,22 +211,31 @@ const analyzeTicker = (symbol) => {
|
|
| 211 |
scrollToBottom()
|
| 212 |
}
|
| 213 |
|
| 214 |
-
//
|
| 215 |
if (data.source === 'RiskManager') {
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
try {
|
| 219 |
-
const rawJson = jsonMatch[1] || jsonMatch[0]
|
| 220 |
-
const parsed = JSON.parse(rawJson)
|
| 221 |
-
|
| 222 |
pendingResult.value = {
|
| 223 |
ticker: symbol,
|
| 224 |
model: providers.find(p => p.id === provider.value)?.name || provider.value,
|
| 225 |
-
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
}
|
| 227 |
-
} catch (e) {
|
| 228 |
-
console.log("Failed to parse result JSON", e)
|
| 229 |
-
}
|
| 230 |
}
|
| 231 |
}
|
| 232 |
} catch (e) {
|
|
@@ -785,6 +794,16 @@ onMounted(() => {
|
|
| 785 |
color: var(--text-primary);
|
| 786 |
}
|
| 787 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
.log-viewport {
|
| 789 |
flex: 1;
|
| 790 |
overflow-y: auto;
|
|
|
|
| 211 |
scrollToBottom()
|
| 212 |
}
|
| 213 |
|
| 214 |
+
// Use backend-provided structured result if available (highly recommended for Groq/Llama)
|
| 215 |
if (data.source === 'RiskManager') {
|
| 216 |
+
if (data.structured_result) {
|
| 217 |
+
console.log("Using backend-provided structured result");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
pendingResult.value = {
|
| 219 |
ticker: symbol,
|
| 220 |
model: providers.find(p => p.id === provider.value)?.name || provider.value,
|
| 221 |
+
...data.structured_result
|
| 222 |
+
}
|
| 223 |
+
} else {
|
| 224 |
+
// Fallback to local regex if structured_result is missing
|
| 225 |
+
const jsonMatch = data.content.match(/```json\s*([\s\S]*?)\s*```/) || data.content.match(/\{[\s\S]*"final_decision"[\s\S]*\}/)
|
| 226 |
+
if (jsonMatch) {
|
| 227 |
+
try {
|
| 228 |
+
const rawJson = jsonMatch[1] || jsonMatch[0]
|
| 229 |
+
const parsed = JSON.parse(rawJson)
|
| 230 |
+
pendingResult.value = {
|
| 231 |
+
ticker: symbol,
|
| 232 |
+
model: providers.find(p => p.id === provider.value)?.name || provider.value,
|
| 233 |
+
...parsed
|
| 234 |
+
}
|
| 235 |
+
} catch (e) {
|
| 236 |
+
console.log("Failed to parse result JSON", e)
|
| 237 |
+
}
|
| 238 |
}
|
|
|
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
}
|
| 241 |
} catch (e) {
|
|
|
|
| 794 |
color: var(--text-primary);
|
| 795 |
}
|
| 796 |
|
| 797 |
+
.header-hint {
|
| 798 |
+
font-size: 11px;
|
| 799 |
+
color: var(--text-tertiary);
|
| 800 |
+
background: rgba(255, 255, 255, 0.05);
|
| 801 |
+
padding: 2px 8px;
|
| 802 |
+
border-radius: 4px;
|
| 803 |
+
text-transform: uppercase;
|
| 804 |
+
letter-spacing: 0.5px;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
.log-viewport {
|
| 808 |
flex: 1;
|
| 809 |
overflow-y: auto;
|