sigma-terminal 3.4.1__py3-none-any.whl → 3.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sigma/app.py CHANGED
@@ -1,1255 +1,445 @@
1
- """Sigma v3.4.1 - Finance Research Agent."""
1
+ """Sigma v3.5.0 - Finance Research Agent."""
2
2
 
3
3
  import asyncio
4
- import os
5
- import re
6
4
  from datetime import datetime
7
- from typing import Optional, List
5
+ from typing import Any, Optional
6
+
7
+ import re # Added top-level import
8
8
 
9
9
  from rich.markdown import Markdown
10
- from rich.panel import Panel
10
+ from rich.markup import escape
11
11
  from rich.table import Table
12
12
  from rich.text import Text
13
+ from rich.panel import Panel
14
+ from rich.align import Align
15
+
13
16
  from textual import on, work
14
17
  from textual.app import App, ComposeResult
15
18
  from textual.binding import Binding
16
- from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
17
- from textual.widgets import Footer, Input, RichLog, Static
18
- from textual.suggester import Suggester
19
-
20
- from .config import LLMProvider, get_settings, save_api_key, AVAILABLE_MODELS, SigmaError, ErrorCode
21
- from .llm import get_llm
22
- from .tools import TOOLS, execute_tool
23
- from .backtest import run_backtest, get_available_strategies, BACKTEST_TOOL
24
-
25
-
26
- __version__ = "3.4.1"
27
- SIGMA = "σ"
28
-
29
- # Common stock tickers for recognition
30
- COMMON_TICKERS = {
31
- "AAPL", "MSFT", "GOOGL", "GOOG", "AMZN", "NVDA", "META", "TSLA", "BRK.A", "BRK.B",
32
- "JPM", "JNJ", "V", "PG", "UNH", "HD", "MA", "DIS", "PYPL", "BAC", "ADBE", "NFLX",
33
- "CRM", "INTC", "AMD", "CSCO", "PEP", "KO", "ABT", "NKE", "MRK", "PFE", "TMO",
34
- "COST", "AVGO", "WMT", "ACN", "LLY", "MCD", "DHR", "TXN", "NEE", "PM", "HON",
35
- "UPS", "BMY", "QCOM", "LOW", "MS", "RTX", "UNP", "ORCL", "IBM", "GE", "CAT",
36
- "SBUX", "AMAT", "GS", "BLK", "DE", "AMT", "NOW", "ISRG", "LMT", "MDLZ", "AXP",
37
- "SYK", "BKNG", "PLD", "GILD", "ADI", "TMUS", "CVS", "MMC", "ZTS", "CB", "C",
38
- "SPY", "QQQ", "IWM", "DIA", "VTI", "VOO", "VXX", "ARKK", "XLF", "XLK", "XLE",
39
- }
40
-
41
-
42
- def format_return(value: float, include_sign: bool = True) -> str:
43
- """Format a return value with color coding. Green for positive, red for negative."""
44
- if value > 0:
45
- sign = "+" if include_sign else ""
46
- return f"[#22c55e]{sign}{value:.2f}%[/#22c55e]"
47
- elif value < 0:
48
- return f"[#ef4444]{value:.2f}%[/#ef4444]"
49
- else:
50
- return f"[dim]{value:.2f}%[/dim]"
51
-
52
-
53
- def format_price_change(price: float, change: float, change_pct: float) -> str:
54
- """Format price with change indicators."""
55
- if change >= 0:
56
- arrow = "^"
57
- color = "#22c55e"
58
- sign = "+"
59
- else:
60
- arrow = "v"
61
- color = "#ef4444"
62
- sign = ""
63
- return f"[bold]${price:.2f}[/bold] [{color}]{arrow} {sign}{change:.2f} ({sign}{change_pct:.2f}%)[/{color}]"
64
-
65
-
66
- def format_metric(label: str, value: str, good: Optional[bool] = None) -> str:
67
- """Format a metric with optional good/bad coloring."""
68
- if good is True:
69
- return f"[dim]{label}:[/dim] [#22c55e]{value}[/#22c55e]"
70
- elif good is False:
71
- return f"[dim]{label}:[/dim] [#ef4444]{value}[/#ef4444]"
72
- else:
73
- return f"[dim]{label}:[/dim] [bold]{value}[/bold]"
74
-
75
-
76
- # Sigma animation frames - smooth color breathing like Claude Code
77
- SIGMA_FRAMES = [
78
- "[bold #1e3a8a]σ[/bold #1e3a8a]",
79
- "[bold #1e40af]σ[/bold #1e40af]",
80
- "[bold #2563eb]σ[/bold #2563eb]",
81
- "[bold #3b82f6]σ[/bold #3b82f6]",
82
- "[bold #60a5fa]σ[/bold #60a5fa]",
83
- "[bold #93c5fd]σ[/bold #93c5fd]",
84
- "[bold #bfdbfe]σ[/bold #bfdbfe]",
85
- "[bold white]σ[/bold white]",
86
- "[bold #bfdbfe]σ[/bold #bfdbfe]",
87
- "[bold #93c5fd]σ[/bold #93c5fd]",
88
- "[bold #60a5fa]σ[/bold #60a5fa]",
89
- "[bold #3b82f6]σ[/bold #3b82f6]",
90
- "[bold #2563eb]σ[/bold #2563eb]",
91
- "[bold #1e40af]σ[/bold #1e40af]",
92
- ]
93
-
94
- # Sigma pulse animation for tool calls (faster pulse)
95
- SIGMA_PULSE_FRAMES = [
96
- "[bold #22c55e]σ[/bold #22c55e]",
97
- "[bold #4ade80]σ[/bold #4ade80]",
98
- "[bold #86efac]σ[/bold #86efac]",
99
- "[bold #bbf7d0]σ[/bold #bbf7d0]",
100
- "[bold #86efac]σ[/bold #86efac]",
101
- "[bold #4ade80]σ[/bold #4ade80]",
102
- "[bold #22c55e]σ[/bold #22c55e]",
103
- "[bold #16a34a]σ[/bold #16a34a]",
104
- ]
105
-
106
- # Tool call spinner frames - classic ASCII spinner
107
- TOOL_SPINNER_FRAMES = [
108
- "|", "/", "-", "\\"
109
- ]
110
-
111
- # Welcome banner - clean design
112
- WELCOME_BANNER = """
113
- [bold #3b82f6]███████╗██╗ ██████╗ ███╗ ███╗ █████╗ [/bold #3b82f6]
114
- [bold #60a5fa]██╔════╝██║██╔════╝ ████╗ ████║██╔══██╗[/bold #60a5fa]
115
- [bold #93c5fd]███████╗██║██║ ███╗██╔████╔██║███████║[/bold #93c5fd]
116
- [bold #60a5fa]╚════██║██║██║ ██║██║╚██╔╝██║██╔══██║[/bold #60a5fa]
117
- [bold #3b82f6]███████║██║╚██████╔╝██║ ╚═╝ ██║██║ ██║[/bold #3b82f6]
118
- [bold #1d4ed8]╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝[/bold #1d4ed8]
119
-
120
- [bold cyan]Finance Research Agent[/bold cyan] [dim]v3.4.1[/dim]
19
+ from textual.containers import Vertical, Horizontal, Container
20
+ from textual.screen import Screen
21
+ from textual.widgets import Input, RichLog, Label, Static, LoadingIndicator
22
+ from textual.reactive import reactive
23
+ from textual.suggester import SuggestFromList
24
+
25
+ from .config import get_settings
26
+ from .core.command_router import CommandRouter, Request
27
+ from .llm.router import get_router
28
+ from .tools.registry import TOOL_REGISTRY
29
+
30
+ __version__ = "3.5.0"
31
+
32
+ # -----------------------------------------------------------------------------
33
+ # CONSTANTS & ASSETS
34
+ # -----------------------------------------------------------------------------
35
+
36
+ SIGMA_ASCII = """
37
+ [#d97757] ██████ ██[/] [#e08e79]██████ ███ ███ █████ [/]
38
+ [#d97757] ██ ██[/] [#e08e79]██ ████ ████ ██ ██[/]
39
+ [#d97757] ███████ ██[/] [#e08e79]██ ███ ██ ████ ██ ███████[/]
40
+ [#d97757] ██ ██[/] [#e08e79]██ ██ ██ ██ ██ ██ ██[/]
41
+ [#d97757] ██████ ██[/] [#e08e79] ██████ ██ ██ ██ ██[/]
121
42
  """
122
43
 
123
- SYSTEM_PROMPT = """You are Sigma, an elite AI-powered Finance Research Agent. You combine the analytical rigor of a quantitative analyst, the market intuition of a seasoned portfolio manager, and the communication clarity of a top financial advisor.
124
-
125
- CORE CAPABILITIES:
126
- - Real-time market data (quotes, prices, volume) via yfinance and Polygon.io
127
- - Chart generation (candlestick, line, technical, comparison charts)
128
- - Fundamental analysis (financials, ratios, earnings, valuations, balance sheets)
129
- - Technical analysis (RSI, MACD, Bollinger Bands, SMA/EMA, support/resistance)
130
- - Valuation analysis (P/E, P/B, PEG, EV/EBITDA with fair value assessment)
131
- - Risk metrics (volatility, VaR, max drawdown, Sharpe, Sortino, Beta, Alpha)
132
- - Earnings analysis (EPS, upcoming dates, quarterly history, surprises)
133
- - Dividend analysis (yield, payout ratio, growth, ex-dividend dates)
134
- - Options analysis (put/call ratio, implied volatility, sentiment)
135
- - Peer comparison (industry peers on key metrics)
136
- - Backtesting (SMA crossover, RSI, MACD, Bollinger, momentum, breakout)
137
- - Market overview with sector performance and economic indicators
138
- - Insider trading and institutional activity tracking
139
- - Financial news search and SEC filings analysis
140
-
141
- CHART GENERATION:
142
- - Use generate_stock_chart for single stock charts (candlestick, line, technical)
143
- - Use generate_comparison_chart for comparing multiple stocks visually
144
- - Charts are saved as PNG files - always mention the file path
145
-
146
- RESPONSE PHILOSOPHY:
147
- 1. BE PROACTIVE: Anticipate follow-up questions and address them
148
- 2. BE THOROUGH: When analyzing, cover fundamentals + technicals + sentiment
149
- 3. BE ACTIONABLE: Always end with clear recommendations or next steps
150
- 4. BE DATA-DRIVEN: Cite specific numbers, dates, and percentages
151
- 5. BE HONEST: Acknowledge uncertainty and risks
152
-
153
- RESPONSE FORMAT:
154
- - Start with a 1-2 sentence executive summary (the key takeaway)
155
- - Use structured sections with clear headers (## format)
156
- - Present comparative data in markdown tables
157
- - Highlight key metrics: **bold** for critical numbers
158
- - For returns: indicate positive (+) or negative (-) clearly
159
- - Use bullet points for easy scanning
160
- - End with "**Bottom Line:**" or "**Recommendation:**"
161
-
162
- RATING SYSTEM (when asked for recommendations):
163
- - STRONG BUY [A+]: Exceptional opportunity, high conviction
164
- - BUY [A]: Favorable outlook, solid fundamentals
165
- - HOLD [B]: Fair value, wait for better entry
166
- - SELL [C]: Concerns outweigh positives
167
- - STRONG SELL [D]: Significant risks, avoid
168
-
169
- DATA GATHERING RULES:
170
- - ALWAYS use tools to fetch current data before answering stock questions
171
- - Use multiple tools for comprehensive analysis (quote + technicals + fundamentals)
172
- - Cross-reference data points for accuracy
173
- - If a tool fails, try alternative approaches or acknowledge the limitation
174
-
175
- PROACTIVE INSIGHTS:
176
- When analyzing any stock, proactively mention:
177
- - Recent earnings surprises or upcoming earnings dates
178
- - Major analyst rating changes
179
- - Unusual volume or price movements
180
- - Relevant sector trends
181
- - Key support/resistance levels
182
- - Comparison to peers when relevant
183
-
184
- FORBIDDEN:
185
- - Never fabricate data or prices
186
- - Never provide data without fetching it first
187
- - Never give buy/sell advice without disclosure of risks
188
- - Never claim certainty about future performance
189
-
190
- Remember: Users trust you for professional-grade financial research. Exceed their expectations with every response."""
191
-
192
- # Enhanced autocomplete suggestions with more variety
193
44
  SUGGESTIONS = [
194
- # Analysis commands
195
- "analyze AAPL",
196
- "analyze MSFT",
197
- "analyze GOOGL",
198
- "analyze NVDA",
199
- "analyze TSLA",
200
- "analyze META",
201
- "analyze AMZN",
202
- "analyze AMD",
203
- "analyze SPY",
204
- # Comparisons
205
- "compare AAPL MSFT GOOGL",
206
- "compare NVDA AMD INTC",
207
- "compare META GOOGL AMZN",
208
- "compare TSLA RIVN LCID",
209
- # Technical
210
- "technical analysis of AAPL",
211
- "technical analysis of SPY",
212
- "technical analysis of NVDA",
213
- "technical analysis of QQQ",
214
- # Backtesting
215
- "backtest SMA crossover on AAPL",
216
- "backtest RSI strategy on SPY",
217
- "backtest MACD on NVDA",
218
- "backtest momentum on QQQ",
219
- # Market
220
- "market overview",
221
- "sector performance",
222
- "what sectors are hot today",
223
- # Quotes
224
- "get quote for AAPL",
225
- "price of NVDA",
226
- "how is TSLA doing",
227
- # Fundamentals
228
- "fundamentals of MSFT",
229
- "financials for AAPL",
230
- "earnings of NVDA",
231
- # Activity
232
- "insider trading for AAPL",
233
- "institutional holders of NVDA",
234
- "analyst recommendations for TSLA",
235
- # Chart generation
236
- "plot AAPL stock chart",
237
- "chart NVDA candlestick",
238
- "show me a chart of TSLA",
239
- "compare AAPL MSFT GOOGL chart",
240
- "technical chart for SPY",
241
- "generate chart for AMZN",
242
- # Advanced analysis
243
- "valuation of AAPL",
244
- "risk metrics for NVDA",
245
- "is TSLA overvalued",
246
- "dividend analysis of JNJ",
247
- "options summary for SPY",
248
- "peer comparison for NVDA",
249
- "earnings analysis of AAPL",
250
- # Natural language queries
251
- "what should I know about AAPL",
252
- "best tech stocks right now",
253
- "should I buy TSLA",
254
- "how risky is NVDA",
255
- "what is the P/E of MSFT",
256
- # Commands
257
- "/help",
258
- "/clear",
259
- "/keys",
260
- "/models",
261
- "/status",
262
- "/backtest",
263
- ]
264
-
265
- # Extended action verbs for smart completion
266
- ACTION_VERBS = [
267
- "analyze", "compare", "show", "get", "what is", "tell me about",
268
- "technical analysis", "fundamentals", "price", "quote", "chart",
269
- "backtest", "insider trading", "institutional", "analyst", "earnings",
270
- "financials", "valuation", "risk", "dividend", "options", "peers",
271
- "news", "sector", "market", "portfolio"
45
+ "analyze", "backtest", "compare", "chart", "quote",
46
+ "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "TSLA", "META",
47
+ "strategy", "momentum", "mean_reversion"
272
48
  ]
273
49
 
274
- # Ticker categories for smart suggestions
275
- TICKER_CATEGORIES = {
276
- "tech": ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "AMD", "INTC", "CRM", "ADBE"],
277
- "finance": ["JPM", "BAC", "GS", "MS", "V", "MA", "BRK.B", "C", "WFC", "AXP"],
278
- "healthcare": ["JNJ", "UNH", "PFE", "MRK", "ABBV", "LLY", "TMO", "ABT", "BMY", "GILD"],
279
- "consumer": ["AMZN", "TSLA", "HD", "NKE", "MCD", "SBUX", "COST", "WMT", "TGT", "LOW"],
280
- "energy": ["XOM", "CVX", "COP", "SLB", "EOG", "MPC", "PSX", "VLO", "OXY", "HAL"],
281
- "etf": ["SPY", "QQQ", "IWM", "DIA", "VTI", "VOO", "VXX", "ARKK", "XLF", "XLK"],
282
- "crypto": ["COIN", "MSTR", "RIOT", "MARA", "HUT"],
283
- "ev": ["TSLA", "RIVN", "LCID", "NIO", "XPEV", "LI"],
284
- "ai": ["NVDA", "AMD", "MSFT", "GOOGL", "META", "PLTR", "AI", "PATH", "SNOW"],
285
- "semiconductor": ["NVDA", "AMD", "INTC", "AVGO", "QCOM", "TXN", "MU", "AMAT", "LRCX", "ASML"],
286
- }
287
50
 
51
+ # -----------------------------------------------------------------------------
52
+ # SCREENS
53
+ # -----------------------------------------------------------------------------
288
54
 
289
- def extract_tickers(text: str) -> List[str]:
290
- """Extract stock tickers from text."""
291
- # Look for common patterns: $AAPL, or standalone uppercase words
292
- # Only match if it's a known ticker or starts with $
293
- words = text.upper().split()
294
- tickers = []
55
+ class ThinkingStatus(Static):
56
+ """Animated thinking indicator like Claude Code."""
295
57
 
296
- for word in words:
297
- # Clean the word
298
- clean = word.strip('.,!?()[]{}":;')
299
-
300
- # Check for $TICKER format
301
- if clean.startswith('$'):
302
- ticker = clean[1:]
303
- if ticker and ticker.isalpha() and len(ticker) <= 5:
304
- tickers.append(ticker)
305
- # Check if it's a known ticker
306
- elif clean in COMMON_TICKERS:
307
- tickers.append(clean)
308
-
309
- return list(dict.fromkeys(tickers)) # Dedupe while preserving order
310
-
311
-
312
- class SigmaSuggester(Suggester):
313
- """Smart autocomplete with fuzzy matching and context awareness."""
314
-
315
- def __init__(self):
316
- super().__init__(use_cache=False, case_sensitive=False)
317
- self._build_suggestion_index()
318
-
319
- def _build_suggestion_index(self):
320
- """Build an index of all possible suggestions for fast lookup."""
321
- self.all_suggestions = []
322
-
323
- # Add static suggestions
324
- self.all_suggestions.extend(SUGGESTIONS)
325
-
326
- # Generate dynamic suggestions for all tickers
327
- for ticker in COMMON_TICKERS:
328
- self.all_suggestions.extend([
329
- f"analyze {ticker}",
330
- f"technical analysis of {ticker}",
331
- f"fundamentals of {ticker}",
332
- f"price of {ticker}",
333
- f"quote {ticker}",
334
- f"insider trading {ticker}",
335
- f"earnings {ticker}",
336
- f"news {ticker}",
337
- ])
338
-
339
- # Add category-based suggestions
340
- for category, tickers in TICKER_CATEGORIES.items():
341
- self.all_suggestions.append(f"best {category} stocks")
342
- self.all_suggestions.append(f"{category} sector performance")
343
- if len(tickers) >= 3:
344
- self.all_suggestions.append(f"compare {' '.join(tickers[:3])}")
345
-
346
- def _fuzzy_match(self, query: str, target: str) -> float:
347
- """Calculate fuzzy match score (0-1). Higher is better."""
348
- query = query.lower()
349
- target = target.lower()
350
-
351
- # Exact prefix match is best
352
- if target.startswith(query):
353
- return 1.0 + len(query) / len(target)
354
-
355
- # Check if all query chars appear in order
356
- q_idx = 0
357
- matches = 0
358
- consecutive = 0
359
- max_consecutive = 0
360
- last_match = -1
361
-
362
- for t_idx, char in enumerate(target):
363
- if q_idx < len(query) and char == query[q_idx]:
364
- matches += 1
365
- if last_match == t_idx - 1:
366
- consecutive += 1
367
- max_consecutive = max(max_consecutive, consecutive)
368
- else:
369
- consecutive = 1
370
- last_match = t_idx
371
- q_idx += 1
372
-
373
- if q_idx < len(query):
374
- # Not all chars matched
375
- # Try substring match
376
- if query in target:
377
- return 0.5
378
- return 0
379
-
380
- # Score based on match quality
381
- score = (matches / len(query)) * 0.5 + (max_consecutive / len(query)) * 0.3
382
- # Bonus for shorter targets (more relevant)
383
- score += (1 - len(target) / 100) * 0.2
384
-
385
- return score
386
-
387
- def _get_context_suggestions(self, value: str) -> List[str]:
388
- """Get context-aware suggestions based on partial input."""
389
- suggestions = []
390
- value_lower = value.lower().strip()
391
-
392
- # Command shortcuts
393
- if value.startswith("/"):
394
- cmd = value_lower[1:]
395
- commands = ["/help", "/clear", "/keys", "/models", "/status", "/backtest",
396
- "/provider", "/model", "/setkey", "/tickers"]
397
- for c in commands:
398
- if c[1:].startswith(cmd):
399
- suggestions.append(c)
400
- return suggestions
401
-
402
- # Detect if user is typing a ticker
403
- words = value.split()
404
- last_word = words[-1] if words else ""
405
-
406
- if last_word.startswith("$") or (last_word.isupper() and len(last_word) >= 1):
407
- ticker_prefix = last_word.lstrip("$").upper()
408
- matching_tickers = [t for t in COMMON_TICKERS if t.startswith(ticker_prefix)]
409
-
410
- # If we have an action verb, complete with ticker
411
- action_words = ["analyze", "compare", "technical", "price", "quote",
412
- "fundamentals", "insider", "earnings", "news"]
413
- prefix = " ".join(words[:-1]).lower() if len(words) > 1 else ""
414
-
415
- for ticker in matching_tickers[:5]:
416
- if prefix:
417
- suggestions.append(f"{prefix} {ticker}")
418
- else:
419
- suggestions.append(f"analyze {ticker}")
420
- return suggestions
421
-
422
- # Natural language patterns
423
- patterns = [
424
- ("ana", "analyze"),
425
- ("tech", "technical analysis of"),
426
- ("fun", "fundamentals of"),
427
- ("comp", "compare"),
428
- ("back", "backtest"),
429
- ("mark", "market overview"),
430
- ("sect", "sector performance"),
431
- ("pri", "price of"),
432
- ("quo", "quote"),
433
- ("ins", "insider trading"),
434
- ("ear", "earnings"),
435
- ("wha", "what should I know about"),
436
- ("sho", "should I buy"),
437
- ("is ", "is NVDA overvalued"),
438
- ("how", "how is"),
439
- ("bes", "best tech stocks"),
440
- ]
441
-
442
- for prefix, expansion in patterns:
443
- if value_lower.startswith(prefix):
444
- if expansion.endswith("of") or expansion.endswith("about"):
445
- # Add popular tickers
446
- for ticker in ["AAPL", "NVDA", "MSFT", "TSLA", "GOOGL"]:
447
- suggestions.append(f"{expansion} {ticker}")
448
- else:
449
- suggestions.append(expansion)
450
-
451
- return suggestions
58
+ DEFAULT_CSS = """
59
+ ThinkingStatus {
60
+ height: 1;
61
+ width: 100%;
62
+ color: #a1a1aa;
63
+ display: none;
64
+ padding-left: 2;
65
+ }
66
+ ThinkingStatus.visible {
67
+ display: block;
68
+ }
69
+ """
452
70
 
453
- async def get_suggestion(self, value: str) -> Optional[str]:
454
- """Get the best autocomplete suggestion."""
455
- if not value or len(value) < 1:
456
- return None
457
-
458
- value_lower = value.lower().strip()
459
-
460
- # Get context-aware suggestions first
461
- context_suggestions = self._get_context_suggestions(value)
462
- if context_suggestions:
463
- return context_suggestions[0]
464
-
465
- # Fuzzy match against all suggestions
466
- scored = []
467
- for suggestion in self.all_suggestions:
468
- score = self._fuzzy_match(value_lower, suggestion)
469
- if score > 0:
470
- scored.append((score, suggestion))
471
-
472
- # Sort by score and return best
473
- scored.sort(key=lambda x: x[0], reverse=True)
474
-
475
- if scored:
476
- return scored[0][1]
477
-
478
- # Last resort: if looks like a ticker, suggest analyze
479
- if value.isupper() and len(value) <= 5 and value.isalpha():
480
- return f"analyze {value}"
71
+ def on_mount(self):
72
+ self.loading = False
73
+ self.frame = 0
74
+ self.frames = ["σ", "o", "O", "0", "O", "o"]
75
+ self.set_interval(0.1, self.animate)
76
+
77
+ def animate(self):
78
+ if self.loading:
79
+ self.frame = (self.frame + 1) % len(self.frames)
80
+ # symbol = self.frames[self.frame]
81
+ # Just pulse the sigma
82
+ if self.frame < 3:
83
+ self.update(Text("σ", style="bold #d97757") + " Analysis in progress...")
84
+ else:
85
+ self.update(Text("σ", style="dim #d97757") + " Analysis in progress...")
86
+
87
+ def start(self):
88
+ self.loading = True
89
+ self.add_class("visible")
481
90
 
482
- return None
483
-
484
-
485
- CSS = """
486
- Screen {
487
- background: #0a0a0f;
488
- }
489
-
490
- * {
491
- scrollbar-size: 1 1;
492
- scrollbar-color: #3b82f6 30%;
493
- scrollbar-color-hover: #60a5fa 50%;
494
- scrollbar-color-active: #93c5fd 70%;
495
- }
496
-
497
- #main-container {
498
- width: 100%;
499
- height: 100%;
500
- background: #0a0a0f;
501
- }
502
-
503
- #chat-area {
504
- height: 1fr;
505
- margin: 1 2;
506
- background: #0a0a0f;
507
- }
508
-
509
- #chat-log {
510
- background: #0a0a0f;
511
- padding: 1 0;
512
- }
513
-
514
- #status-bar {
515
- height: 3;
516
- background: #0f1419;
517
- border-top: solid #1e293b;
518
- padding: 0 2;
519
- dock: bottom;
520
- }
521
-
522
- #status-content {
523
- width: 100%;
524
- height: 100%;
525
- content-align: left middle;
526
- }
527
-
528
- #thinking-indicator {
529
- width: auto;
530
- height: 1;
531
- content-align: center middle;
532
- display: none;
533
- }
534
-
535
- #thinking-indicator.visible {
536
- display: block;
537
- }
538
-
539
- #tool-calls-display {
540
- width: 100%;
541
- height: auto;
542
- max-height: 8;
543
- background: #0f1419;
544
- border: round #1e293b;
545
- margin: 0 2;
546
- padding: 0 1;
547
- display: none;
548
- }
549
-
550
- #tool-calls-display.visible {
551
- display: block;
552
- }
553
-
554
- #input-area {
555
- height: 5;
556
- padding: 1 2;
557
- background: #0f1419;
558
- }
559
-
560
- #input-row {
561
- height: 3;
562
- width: 100%;
563
- }
564
-
565
- #sigma-indicator {
566
- width: 4;
567
- height: 3;
568
- content-align: center middle;
569
- background: transparent;
570
- }
571
-
572
- #prompt-input {
573
- width: 1fr;
574
- background: #1e293b;
575
- border: tall #3b82f6;
576
- color: #f8fafc;
577
- padding: 0 1;
578
- }
579
-
580
- #prompt-input:focus {
581
- border: tall #60a5fa;
582
- background: #1e3a5f;
583
- }
584
-
585
- #prompt-input.-autocomplete {
586
- border: tall #22c55e;
587
- }
588
-
589
- #ticker-highlight {
590
- width: auto;
591
- height: 1;
592
- padding: 0 1;
593
- background: transparent;
594
- color: #22d3ee;
595
- }
596
-
597
- Footer {
598
- background: #0d1117;
599
- height: 1;
600
- dock: bottom;
601
- }
91
+ def stop(self):
92
+ self.loading = False
93
+ self.remove_class("visible")
602
94
 
603
- Footer > .footer--highlight {
604
- background: transparent;
605
- }
606
95
 
607
- Footer > .footer--key {
608
- background: #1a1a2e;
609
- color: #f59e0b;
610
- text-style: bold;
611
- }
96
+ class SplashScreen(Screen):
612
97
 
613
- Footer > .footer--description {
614
- color: #6b7280;
615
- }
98
+ BINDINGS = [("enter", "start_app", "Start")]
616
99
 
617
- #help-panel {
618
- width: 100%;
619
- height: auto;
620
- padding: 1;
621
- background: #0d1117;
622
- border: solid #3b82f6;
623
- margin: 1 2;
624
- display: none;
625
- }
100
+ def compose(self) -> ComposeResult:
101
+ yield Container(
102
+ Static(Panel(Align.center("[#e4e4e7]Welcome to Sigma[/]"), border_style="#d97757", padding=(0, 2)), id="welcome-badge"),
103
+ Static(Align.center(SIGMA_ASCII), id="ascii-art"),
104
+ Label("Press Enter to continue", id="press-enter"),
105
+ id="splash-container"
106
+ )
626
107
 
627
- #help-panel.visible {
628
- display: block;
629
- }
630
- """
108
+ def action_start_app(self):
109
+ self.app.switch_screen("main")
631
110
 
632
111
 
633
- class ToolCallDisplay(Static):
634
- """Animated display for tool calls - professional tool execution view."""
112
+ class MainScreen(Screen):
113
+ """Main chat interface."""
635
114
 
636
- def __init__(self, *args, **kwargs):
637
- super().__init__(*args, **kwargs)
638
- self.tool_calls: List[dict] = []
639
- self.frame = 0
640
- self.timer = None
641
-
642
- def add_tool_call(self, name: str, status: str = "running"):
643
- """Add a tool call to the display."""
644
- # Format tool name nicely
645
- display_name = name.replace("_", " ").title()
646
- self.tool_calls.append({"name": name, "display": display_name, "status": status, "frame": 0})
647
- self.add_class("visible")
648
- self._update_display()
649
- if not self.timer:
650
- self.timer = self.set_interval(0.06, self._animate) # Faster animation
651
-
652
- def complete_tool_call(self, name: str):
653
- """Mark a tool call as complete."""
654
- for tc in self.tool_calls:
655
- if tc["name"] == name and tc["status"] == "running":
656
- tc["status"] = "complete"
657
- break
658
- self._update_display()
659
-
660
- def clear(self):
661
- """Clear all tool calls."""
662
- self.tool_calls = []
663
- if self.timer:
664
- self.timer.stop()
665
- self.timer = None
666
- self.remove_class("visible")
667
- self.update("")
668
-
669
- def _animate(self):
670
- """Animate the spinner."""
671
- self.frame = (self.frame + 1) % len(TOOL_SPINNER_FRAMES)
672
- for tc in self.tool_calls:
673
- if tc["status"] == "running":
674
- tc["frame"] = self.frame
675
- self._update_display()
676
-
677
- def _update_display(self):
678
- """Update the tool calls display content."""
679
- if not self.tool_calls:
680
- self.update("")
681
- return
682
-
683
- lines = []
684
- for tc in self.tool_calls:
685
- if tc["status"] == "running":
686
- spinner = TOOL_SPINNER_FRAMES[tc["frame"] % len(TOOL_SPINNER_FRAMES)]
687
- lines.append(f" [bold #60a5fa]{spinner}[/bold #60a5fa] [bold white]{tc['display']}[/bold white] [dim italic]running...[/dim italic]")
688
- else:
689
- lines.append(f" [bold #22c55e][OK][/bold #22c55e] [bold white]{tc['display']}[/bold white] [#22c55e]done[/#22c55e]")
690
-
691
- self.update(Text.from_markup("\n".join(lines)))
115
+ BINDINGS = [
116
+ Binding("ctrl+l", "clear_chat", "Clear"),
117
+ Binding("ctrl+b", "toggle_sidebar", "Sidebar"),
118
+ ]
692
119
 
120
+ def compose(self) -> ComposeResult:
121
+ with Horizontal():
122
+ # Main Chat Area
123
+ with Vertical(id="chat-area"):
124
+ # Use a specific class or check if can_focus can be disabled here for RichLog
125
+ # In Textual, RichLog is focusable by default. We disable it to prevent it stealing focus from Input.
126
+ log = RichLog(id="chat-log", wrap=True, highlight=True, markup=True)
127
+ log.can_focus = False
128
+ yield log
129
+ yield ThinkingStatus(id="thinking")
130
+ yield Input(
131
+ placeholder="Ask Sigma...",
132
+ id="prompt",
133
+ suggester=SuggestFromList(SUGGESTIONS, case_sensitive=False)
134
+ )
135
+
136
+ # Sidebar (Hidden by default)
137
+ with Vertical(id="sidebar"):
138
+ yield Label("TRACE", classes="sidebar-header")
139
+ tracelog = RichLog(id="trace-log", wrap=True, highlight=True, markup=True)
140
+ tracelog.can_focus = False
141
+ yield tracelog
693
142
 
694
- class SigmaIndicator(Static):
695
- """Animated sigma indicator - typewriter style when active."""
696
-
697
- def __init__(self, *args, **kwargs):
698
- super().__init__(*args, **kwargs)
699
- self.active = False
700
- self.mode = "idle" # idle, thinking, tool
701
- self.frame = 0
702
- self.timer = None
703
-
704
143
  def on_mount(self):
705
- self.update(Text.from_markup(f"[bold blue]{SIGMA}[/bold blue]"))
706
-
707
- def set_active(self, active: bool, mode: str = "thinking"):
708
- self.active = active
709
- self.mode = mode if active else "idle"
710
- if active and not self.timer:
711
- self.frame = 0
712
- # Fast smooth animation - 0.05s for thinking, 0.04s for tool calls
713
- interval = 0.05 if mode == "thinking" else 0.04
714
- self.timer = self.set_interval(interval, self._animate)
715
- elif not active and self.timer:
716
- self.timer.stop()
717
- self.timer = None
718
- self.update(Text.from_markup(f"[bold blue]{SIGMA}[/bold blue]"))
719
-
720
- def _animate(self):
721
- if self.mode == "thinking":
722
- # Typewriter effect: s -> si -> sig -> sigm -> sigma -> sigm -> ...
723
- self.frame = (self.frame + 1) % len(SIGMA_FRAMES)
724
- self.update(Text.from_markup(SIGMA_FRAMES[self.frame]))
725
- else:
726
- # Pulse effect for tool calls
727
- self.frame = (self.frame + 1) % len(SIGMA_PULSE_FRAMES)
728
- self.update(Text.from_markup(SIGMA_PULSE_FRAMES[self.frame]))
729
-
730
-
731
- class TickerHighlight(Static):
732
- """Display detected tickers in real-time."""
733
-
734
- def update_tickers(self, text: str):
735
- """Update displayed tickers based on input."""
736
- tickers = extract_tickers(text)
737
- if tickers:
738
- ticker_text = " ".join([f"[cyan]${t}[/cyan]" for t in tickers[:3]])
739
- self.update(Text.from_markup(ticker_text))
740
- else:
741
- self.update("")
742
-
743
-
744
- class ChatLog(RichLog):
745
- """Chat log with rich formatting."""
746
-
747
- def write_user(self, message: str):
748
- # Highlight any tickers in user message
749
- highlighted = message
750
- for ticker in extract_tickers(message):
751
- highlighted = re.sub(
752
- rf'\b{ticker}\b',
753
- f'[cyan]{ticker}[/cyan]',
754
- highlighted,
755
- flags=re.IGNORECASE
144
+ self.query_one("#prompt").focus()
145
+ # Initial greeting
146
+ self.query_one("#chat-log", RichLog).write(
147
+ Text.assemble(
148
+ ("σ ", "bold #d97757"),
149
+ ("Sigma initialized. ", "bold #e4e4e7"),
150
+ ("Ready for research.", "dim #a1a1aa")
756
151
  )
757
-
758
- self.write(Panel(
759
- Text.from_markup(highlighted) if '[cyan]' in highlighted else Text(message, style="white"),
760
- title="[bold blue]You[/bold blue]",
761
- border_style="blue",
762
- padding=(0, 1),
763
- ))
764
-
765
- def write_assistant(self, message: str):
766
- self.write(Panel(
767
- Markdown(message),
768
- title=f"[bold cyan]{SIGMA} Sigma[/bold cyan]",
769
- border_style="cyan",
770
- padding=(0, 1),
771
- ))
772
-
773
- def write_tool(self, tool_name: str):
774
- # This is now handled by ToolCallDisplay
775
- pass
776
-
777
- def write_error(self, message: str):
778
- self.write(Panel(Text(message, style="red"), title="[red]Error[/red]", border_style="red"))
779
-
780
- def write_system(self, message: str):
781
- self.write(Text.from_markup(f"[dim]{message}[/dim]"))
782
-
783
- def write_welcome(self):
784
- self.write(Text.from_markup(WELCOME_BANNER))
152
+ )
785
153
 
786
154
 
155
+ # -----------------------------------------------------------------------------
156
+ # MAIN APPLICATION
157
+ # -----------------------------------------------------------------------------
158
+
787
159
  class SigmaApp(App):
788
- """Sigma Finance Research Agent."""
789
-
790
- TITLE = "Sigma"
791
- CSS = CSS
792
-
793
- BINDINGS = [
794
- Binding("ctrl+l", "clear", "Clear"),
795
- Binding("ctrl+m", "models", "Models"),
796
- Binding("ctrl+h", "help_toggle", "Help"),
797
- Binding("ctrl+p", "palette", "palette", show=True),
798
- Binding("escape", "cancel", show=False),
799
- ]
800
-
801
- def __init__(self):
802
- super().__init__()
803
- self.settings = get_settings()
804
- self.llm = None
805
- self.conversation = []
806
- self.is_processing = False
807
- self.history: List[str] = []
808
- self.history_idx = -1
809
- self.show_help = False
810
-
811
- def compose(self) -> ComposeResult:
812
- yield Container(
813
- ScrollableContainer(
814
- ChatLog(id="chat-log", highlight=True, markup=True),
815
- id="chat-area",
816
- ),
817
- ToolCallDisplay(id="tool-calls-display"),
818
- Static(id="help-panel"),
819
- Container(
820
- Horizontal(
821
- SigmaIndicator(id="sigma-indicator"),
822
- Input(
823
- placeholder="Ask about any stock, market, or strategy... (Tab to autocomplete)",
824
- id="prompt-input",
825
- suggester=SigmaSuggester(),
826
- ),
827
- TickerHighlight(id="ticker-highlight"),
828
- id="input-row",
829
- ),
830
- id="input-area",
831
- ),
832
- id="main-container",
833
- )
834
- yield Footer()
835
-
160
+ CSS = """
161
+ /* --- COLOR PALETTE (Claude Code Dark) --- */
162
+ $bg: #0f0f0f; /* Very dark grey/black */
163
+ $surface: #1a1a1a; /* Slightly lighter surface */
164
+ $text: #e4e4e7; /* Off-white */
165
+ $dim: #a1a1aa; /* Muted text */
166
+ $accent: #d97757; /* Claude-like Orange/Peach */
167
+ $blue: #3b82f6; /* Link/Action Blue */
168
+
169
+ Screen {
170
+ background: $bg;
171
+ color: $text;
172
+ }
173
+
174
+ /* --- SPLASH SCREEN --- */
175
+ #splash-container {
176
+ align: center middle;
177
+ height: 100%;
178
+ }
179
+
180
+ #welcome-badge {
181
+ width: auto;
182
+ margin-bottom: 2;
183
+ background: $bg;
184
+ }
185
+
186
+ #ascii-art {
187
+ margin-bottom: 4;
188
+ color: $accent;
189
+ }
190
+
191
+ #press-enter {
192
+ color: $dim;
193
+ text-style: bold;
194
+ }
195
+
196
+ /* --- MAIN CHAT LAYOUT --- */
197
+ #chat-area {
198
+ width: 1fr;
199
+ height: 100%;
200
+ padding: 1 2;
201
+ }
202
+
203
+ #chat-log {
204
+ width: 100%;
205
+ height: 1fr;
206
+ background: $bg;
207
+ border: none;
208
+ margin-bottom: 1;
209
+ }
210
+
211
+ Input {
212
+ width: 100%;
213
+ height: 3;
214
+ background: $surface;
215
+ border: solid $dim;
216
+ color: $text;
217
+ padding: 0 1;
218
+ }
219
+
220
+ Input:focus {
221
+ border: solid $accent;
222
+ background: $surface;
223
+ }
224
+
225
+ Input .suggestion {
226
+ color: $dim;
227
+ }
228
+
229
+ /* --- SIDEBAR --- */
230
+ #sidebar {
231
+ width: 40;
232
+ dock: right;
233
+ background: $surface;
234
+ border-left: solid $dim 20%;
235
+ display: none;
236
+ }
237
+
238
+ #sidebar.visible {
239
+ display: block;
240
+ }
241
+
242
+ .sidebar-header {
243
+ background: $surface;
244
+ color: $dim;
245
+ text-align: center;
246
+ text-style: bold;
247
+ padding: 1;
248
+ border-bottom: solid $dim 20%;
249
+ }
250
+
251
+ #trace-log {
252
+ background: $surface;
253
+ padding: 1;
254
+ }
255
+ """
256
+
257
+ SCREENS = {
258
+ "splash": SplashScreen,
259
+ "main": MainScreen
260
+ }
261
+
836
262
  def on_mount(self):
837
- chat = self.query_one("#chat-log", ChatLog)
838
- chat.write_welcome()
263
+ # Initialize Core Logic
264
+ self.router = CommandRouter()
265
+ try:
266
+ self.llm_router = get_router(get_settings())
267
+ except Exception:
268
+ self.llm_router = None
269
+
270
+ self.push_screen("splash")
271
+
272
+ @on(Input.Submitted)
273
+ async def on_input(self, event: Input.Submitted):
274
+ query = event.value
275
+ if not query.strip(): return
839
276
 
840
- provider = getattr(self.settings.default_provider, 'value', str(self.settings.default_provider))
841
- chat.write_system(f"{SIGMA} Provider: [bold]{provider}[/bold] | Model: [bold]{self.settings.default_model}[/bold]")
842
- chat.write_system(f"{SIGMA} Type [cyan]/help[/cyan] for commands • [cyan]/keys[/cyan] to set up API keys")
843
- chat.write_system("")
277
+ # Clear input
278
+ event.input.value = ""
844
279
 
845
- self._init_llm()
846
- self.query_one("#prompt-input", Input).focus()
847
-
848
- def _init_llm(self):
849
- """Initialize the LLM client with proper error handling."""
280
+ # Get chat log - relative to the screen containing the input
281
+ # This fixes the issue where self.query_one fails on App or wrong screen
850
282
  try:
851
- self.llm = get_llm(self.settings.default_provider, self.settings.default_model)
852
- except SigmaError as e:
853
- chat = self.query_one("#chat-log", ChatLog)
854
- chat.write_error(f"[E{e.code}] {e.message}")
855
- if e.details.get("hint"):
856
- chat.write_system(f"[dim]Hint: {e.details['hint']}[/dim]")
857
- self.llm = None
283
+ chat_log = event.control.screen.query_one("#chat-log", RichLog)
858
284
  except Exception as e:
859
- chat = self.query_one("#chat-log", ChatLog)
860
- chat.write_error(f"[E{ErrorCode.PROVIDER_ERROR}] Failed to initialize: {str(e)[:100]}")
861
- chat.write_system("[dim]Use /keys to configure API keys[/dim]")
862
- self.llm = None
863
-
864
- @on(Input.Changed)
865
- def on_input_change(self, event: Input.Changed):
866
- """Update ticker highlight as user types."""
867
- ticker_display = self.query_one("#ticker-highlight", TickerHighlight)
868
- ticker_display.update_tickers(event.value)
869
-
870
- @on(Input.Submitted)
871
- def handle_input(self, event: Input.Submitted):
872
- if self.is_processing:
285
+ self.notify(f"UI Error: Could not find chat log on active screen. {e}", severity="error")
873
286
  return
874
-
875
- text = event.value.strip()
876
- if not text:
877
- return
878
-
879
- self.query_one("#prompt-input", Input).value = ""
880
- self.history.append(text)
881
- self.history_idx = len(self.history)
882
-
883
- chat = self.query_one("#chat-log", ChatLog)
884
-
885
- if text.startswith("/"):
886
- self._handle_command(text, chat)
887
- else:
888
- chat.write_user(text)
889
- self._process_query(text, chat)
890
-
891
- def _handle_command(self, cmd: str, chat: ChatLog):
892
- parts = cmd.lower().split()
893
- command = parts[0]
894
- args = parts[1:] if len(parts) > 1 else []
895
-
896
- if command == "/help":
897
- self._show_comprehensive_help(chat)
898
- elif command == "/clear":
899
- chat.clear()
900
- self.conversation = []
901
- chat.write_system("Chat cleared")
902
- elif command == "/keys":
903
- self._show_keys(chat)
904
- elif command == "/models":
905
- self._show_models(chat)
906
- elif command == "/status":
907
- self._show_status(chat)
908
- elif command == "/backtest":
909
- self._show_strategies(chat)
910
- elif command == "/provider" and args:
911
- self._switch_provider(args[0], chat)
912
- elif command == "/model" and args:
913
- self._switch_model(args[0], chat)
914
- elif command.startswith("/setkey") and len(parts) >= 3:
915
- self._set_key(parts[1], parts[2], chat)
916
- elif command == "/tickers":
917
- self._show_popular_tickers(chat)
918
- else:
919
- chat.write_error(f"Unknown command: {command}. Type /help for available commands.")
920
-
921
- def _show_comprehensive_help(self, chat: ChatLog):
922
- """Show comprehensive help with examples."""
923
- help_text = f"""
924
- [bold white on #1e3a8a] [/bold white on #1e3a8a]
925
- [bold white on #1e3a8a] {SIGMA} S I G M A H E L P C E N T E R [/bold white on #1e3a8a]
926
- [bold white on #1e3a8a] [/bold white on #1e3a8a]
927
-
928
- [bold #3b82f6]GETTING STARTED[/bold #3b82f6]
929
- Type naturally - Sigma understands finance queries:
930
- [dim]>>[/dim] analyze AAPL [dim]Full company analysis[/dim]
931
- [dim]>>[/dim] compare NVDA AMD INTC [dim]Side-by-side comparison[/dim]
932
- [dim]>>[/dim] technical analysis SPY [dim]RSI, MACD, Bollinger Bands[/dim]
933
- [dim]>>[/dim] plot TSLA chart [dim]Generate price chart[/dim]
934
- [dim]>>[/dim] backtest SMA on AAPL [dim]Strategy simulation[/dim]
935
-
936
- [bold #3b82f6]COMMANDS[/bold #3b82f6]
937
- [cyan]/help[/cyan] Full help documentation
938
- [cyan]/clear[/cyan] Clear conversation history
939
- [cyan]/keys[/cyan] API key configuration
940
- [cyan]/models[/cyan] Available AI models
941
- [cyan]/status[/cyan] Current settings
942
- [cyan]/backtest[/cyan] Backtesting strategies
943
- [cyan]/provider[/cyan] [dim]<name>[/dim] Switch provider (google, openai, anthropic)
944
- [cyan]/model[/cyan] [dim]<name>[/dim] Switch model
945
- [cyan]/setkey[/cyan] [dim]<p> <key>[/dim] Set API key
946
- [cyan]/tickers[/cyan] Popular ticker list
947
-
948
- [bold #3b82f6]ANALYSIS CAPABILITIES[/bold #3b82f6]
949
- [bold]Fundamental[/bold] financials, earnings, valuation, balance sheet
950
- [bold]Technical[/bold] RSI, MACD, SMA/EMA, Bollinger, support/resistance
951
- [bold]Sentiment[/bold] analyst ratings, insider trades, institutional holdings
952
- [bold]Market[/bold] sector performance, economic indicators, market news
953
- [bold]Charts[/bold] candlestick, line, technical, comparison charts
954
-
955
- [bold #3b82f6]BACKTEST STRATEGIES[/bold #3b82f6]
956
- [bold]sma_crossover[/bold] SMA 20/50 crossover signals
957
- [bold]rsi[/bold] RSI mean reversion (30/70)
958
- [bold]macd[/bold] MACD momentum signals
959
- [bold]bollinger[/bold] Bollinger Bands bounce
960
- [bold]momentum[/bold] Dual momentum strategy
961
- [bold]breakout[/bold] Price breakout signals
962
-
963
- [bold #3b82f6]KEYBOARD[/bold #3b82f6]
964
- [bold]Tab[/bold] Smart autocomplete
965
- [bold]Ctrl+L[/bold] Clear chat
966
- [bold]Ctrl+M[/bold] Models menu
967
- [bold]Ctrl+H[/bold] Quick help
968
- [bold]Ctrl+P[/bold] Command palette
969
-
970
- [dim]Returns: [/dim][#22c55e]+green = gain[/#22c55e][dim], [/dim][#ef4444]-red = loss[/#ef4444][dim] | Tickers auto-detected from input[/dim]
971
- """
972
- chat.write(Panel(
973
- Text.from_markup(help_text),
974
- title=f"[bold cyan]{SIGMA} Help[/bold cyan]",
975
- border_style="#3b82f6",
976
- padding=(0, 1),
977
- ))
978
-
979
- def _show_popular_tickers(self, chat: ChatLog):
980
- """Show popular tickers organized by category."""
981
- tickers_text = """
982
- [bold]Tech Giants[/bold]: AAPL, MSFT, GOOGL, AMZN, META, NVDA
983
- [bold]Semiconductors[/bold]: NVDA, AMD, INTC, AVGO, QCOM, TSM
984
- [bold]EVs & Auto[/bold]: TSLA, RIVN, LCID, F, GM
985
- [bold]Finance[/bold]: JPM, BAC, GS, MS, V, MA
986
- [bold]Healthcare[/bold]: JNJ, PFE, UNH, MRK, ABBV
987
- [bold]ETFs[/bold]: SPY, QQQ, IWM, DIA, VTI, VOO
988
- [bold]Sector ETFs[/bold]: XLK, XLF, XLE, XLV, XLI
989
- """
990
- chat.write(Panel(
991
- Text.from_markup(tickers_text),
992
- title=f"[cyan]{SIGMA} Popular Tickers[/cyan]",
993
- border_style="dim",
994
- ))
995
-
996
- def _show_keys(self, chat: ChatLog):
997
- """Show comprehensive API key management interface."""
998
- keys_help = f"""
999
- [bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]
1000
- [bold] {SIGMA} API KEY MANAGER [/bold]
1001
- [bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]
1002
-
1003
- [bold yellow]QUICK SETUP[/bold yellow]
1004
- Set a key: [cyan]/setkey <provider> <your-api-key>[/cyan]
1005
-
1006
- [bold yellow]LLM PROVIDERS[/bold yellow]
1007
- [bold]google[/bold] → https://aistudio.google.com/apikey
1008
- [bold]openai[/bold] → https://platform.openai.com/api-keys
1009
- [bold]anthropic[/bold] → https://console.anthropic.com/settings/keys
1010
- [bold]groq[/bold] → https://console.groq.com/keys [dim](free!)[/dim]
1011
- [bold]xai[/bold] → https://console.x.ai
1012
-
1013
- [bold yellow]DATA PROVIDERS[/bold yellow] [dim](optional - enhances data quality)[/dim]
1014
- [bold]polygon[/bold] → https://polygon.io/dashboard/api-keys
1015
-
1016
- [bold yellow]EXAMPLES[/bold yellow]
1017
- /setkey google AIzaSyB...
1018
- /setkey openai sk-proj-...
1019
- /setkey polygon abc123...
1020
- /provider groq [dim]← switch to groq[/dim]
1021
287
 
1022
- [bold yellow]TIPS[/bold yellow]
1023
- [green]Groq[/green] is free and fast - great for starting out
1024
- • [green]Ollama[/green] runs locally - no key needed (/provider ollama)
1025
- • Keys are stored in [dim]~/.sigma/config.env[/dim]
1026
- """
1027
- chat.write(Panel(
1028
- Text.from_markup(keys_help),
1029
- title=f"[bold cyan]{SIGMA} API Keys[/bold cyan]",
1030
- border_style="cyan",
1031
- padding=(0, 1),
1032
- ))
1033
- self._show_status(chat)
1034
-
1035
- def _show_status(self, chat: ChatLog):
1036
- """Show current configuration status."""
1037
- table = Table(show_header=True, box=None, padding=(0, 2), title=f"{SIGMA} Current Status")
1038
- table.add_column("Setting", style="bold")
1039
- table.add_column("Value")
1040
- table.add_column("Status")
1041
-
1042
- provider = getattr(self.settings.default_provider, 'value', str(self.settings.default_provider))
1043
- table.add_row("Provider", provider, "[green][*][/green] Active")
1044
- table.add_row("Model", self.settings.default_model, "")
1045
- table.add_row("", "", "")
1046
-
1047
- # LLM Keys - show FULL keys (no masking)
1048
- llm_keys = [
1049
- ("Google", self.settings.google_api_key, "google"),
1050
- ("OpenAI", self.settings.openai_api_key, "openai"),
1051
- ("Anthropic", self.settings.anthropic_api_key, "anthropic"),
1052
- ("Groq", self.settings.groq_api_key, "groq"),
1053
- ("xAI", self.settings.xai_api_key, "xai"),
1054
- ]
1055
- for name, key, prov in llm_keys:
1056
- if key:
1057
- # Show full key - no masking
1058
- display_key = key
1059
- status = "[green]OK[/green]"
1060
- else:
1061
- display_key = "[dim]not set[/dim]"
1062
- status = "[dim]--[/dim]"
1063
-
1064
- # Highlight active provider
1065
- if prov == provider:
1066
- name = f"[bold cyan]{name}[/bold cyan]"
1067
- table.add_row(f" {name}", Text.from_markup(display_key), Text.from_markup(status))
288
+ # Prepare display text
289
+ display_query = escape(query)
1068
290
 
1069
- table.add_row("", "", "")
1070
-
1071
- # Data Keys - show FULL keys
1072
- polygon_key = getattr(self.settings, 'polygon_api_key', None)
1073
- if polygon_key:
1074
- table.add_row(" Polygon", polygon_key, Text.from_markup("[green]OK[/green]"))
1075
- else:
1076
- table.add_row(" Polygon", Text.from_markup("[dim]not set[/dim]"), Text.from_markup("[dim]optional[/dim]"))
1077
-
1078
- chat.write(Panel(table, border_style="dim"))
1079
-
1080
- def _show_models(self, chat: ChatLog):
1081
- table = Table(title=f"{SIGMA} Models", show_header=True, border_style="dim")
1082
- table.add_column("Provider", style="cyan")
1083
- table.add_column("Models")
1084
- for p, m in AVAILABLE_MODELS.items():
1085
- table.add_row(p, ", ".join(m))
1086
- chat.write(table)
1087
-
1088
- def _show_strategies(self, chat: ChatLog):
1089
- strategies = get_available_strategies()
1090
- table = Table(title=f"{SIGMA} Strategies", show_header=True, border_style="dim")
1091
- table.add_column("Name", style="cyan")
1092
- table.add_column("Description")
1093
- for k, v in strategies.items():
1094
- table.add_row(k, v.get('description', ''))
1095
- chat.write(table)
1096
-
1097
- def _switch_provider(self, provider: str, chat: ChatLog):
1098
- valid = ["google", "openai", "anthropic", "groq", "xai", "ollama"]
1099
- if provider not in valid:
1100
- chat.write_error(f"Invalid. Use: {', '.join(valid)}")
1101
- return
291
+ # Highlight tickers
292
+ try:
293
+ req = self.router.parse(query)
294
+ for ticker in req.tickers:
295
+ escaped_ticker = re.escape(ticker)
296
+ # Apply green highlight
297
+ display_query = re.sub(
298
+ f"(?i)\\b{escaped_ticker}\\b",
299
+ f"[bold #22c55e]{ticker.upper()}[/]",
300
+ display_query
301
+ )
302
+ except Exception:
303
+ pass
304
+
305
+ # Write to log
1102
306
  try:
1103
- self.settings.default_provider = LLMProvider(provider)
1104
- if provider in AVAILABLE_MODELS:
1105
- self.settings.default_model = AVAILABLE_MODELS[provider][0]
1106
- self._init_llm()
1107
- chat.write_system(f"Switched to {provider}")
307
+ chat_log.write(
308
+ Text.assemble(
309
+ ("❯ ", "bold #d97757"),
310
+ Text.from_markup(display_query)
311
+ )
312
+ )
313
+ chat_log.write("") # Add spacing
1108
314
  except Exception as e:
1109
- chat.write_error(str(e))
1110
-
1111
- def _switch_model(self, model: str, chat: ChatLog):
1112
- self.settings.default_model = model
1113
- self._init_llm()
1114
- chat.write_system(f"Model: {model}")
1115
-
1116
- def _set_key(self, provider: str, key: str, chat: ChatLog):
1117
- """Save an API key for a provider."""
1118
- # Normalize provider name
1119
- provider = provider.lower().strip()
1120
- valid_providers = ["google", "openai", "anthropic", "groq", "xai", "polygon", "alphavantage", "exa"]
315
+ self.notify(f"Display Error: {e}", severity="error")
316
+ chat_log.write(f"❯ {query}")
1121
317
 
1122
- if provider not in valid_providers:
1123
- chat.write_error(f"[E{ErrorCode.INVALID_INPUT}] Unknown provider: {provider}")
1124
- chat.write_system(f"Valid providers: {', '.join(valid_providers)}")
1125
- return
318
+ # Pass the specific chat_log instance to the worker to avoid re-querying failure
319
+ self.run_query(query, chat_log)
320
+
321
+ @work
322
+ async def run_query(self, query: str, chat_log: RichLog):
323
+ # Trace log logic simplified
324
+ trace_log = None
325
+ try:
326
+ # Try to find trace log on the same screen as chat_log
327
+ trace_log = chat_log.screen.query_one("#trace-log", RichLog)
328
+ except:
329
+ pass
1126
330
 
1127
- # Basic key validation
1128
- key = key.strip()
1129
- if len(key) < 10:
1130
- chat.write_error(f"[E{ErrorCode.INVALID_INPUT}] API key seems too short")
1131
- return
331
+ # UI Animation: Thinking
332
+ try:
333
+ thinker = chat_log.screen.query_one(ThinkingStatus)
334
+ thinker.start()
335
+ except:
336
+ thinker = None
1132
337
 
1133
338
  try:
1134
- success = save_api_key(provider, key)
1135
- if success:
1136
- # Reload settings
1137
- self.settings = get_settings()
1138
-
1139
- # Show success with FULL key (no masking)
1140
- chat.write_system(f"[green]OK[/green] {SIGMA} Key saved for [bold]{provider}[/bold]: {key}")
1141
-
1142
- # Auto-switch to this provider if it's an LLM provider and we don't have an LLM
1143
- llm_providers = ["google", "openai", "anthropic", "groq", "xai"]
1144
- if provider in llm_providers:
1145
- if not self.llm or provider == getattr(self.settings.default_provider, 'value', ''):
1146
- self._switch_provider(provider, chat)
339
+ req = self.router.parse(query)
340
+ if trace_log:
341
+ trace_log.write(f"[dim]Action:[/dim] {req.action}")
342
+
343
+ if req.is_command:
344
+ await self.handle_command(req, chat_log, trace_log)
1147
345
  else:
1148
- chat.write_error(f"[E{ErrorCode.UNKNOWN_ERROR}] Failed to save key")
346
+ await self.handle_chat(req, chat_log, trace_log)
347
+
1149
348
  except Exception as e:
1150
- chat.write_error(f"[E{ErrorCode.UNKNOWN_ERROR}] {str(e)}")
1151
-
1152
- @work(exclusive=True)
1153
- async def _process_query(self, query: str, chat: ChatLog):
1154
- if not self.llm:
1155
- chat.write_error(f"[E{ErrorCode.API_KEY_MISSING}] No LLM configured. Use /keys to set up.")
349
+ chat_log.write(f"[red]Error: {e}[/red]")
350
+
351
+ finally:
352
+ if thinker:
353
+ thinker.stop()
354
+
355
+ async def handle_command(self, req: Request, chat_log: RichLog, trace_log: Optional[RichLog]):
356
+ if req.action == "backtest":
357
+ chat_log.write("[dim]Running backtest...[/dim]")
358
+ try:
359
+ symbol = req.tickers[0] if req.tickers else "SPY"
360
+ strategy = req.details.get("strategy") or "momentum"
361
+ from .backtest import run_backtest
362
+
363
+ result = await asyncio.to_thread(run_backtest, symbol, strategy, "1y")
364
+
365
+ if "error" in result:
366
+ chat_log.write(f"[red]Failed: {result['error']}[/red]")
367
+ else:
368
+ perf = result.get("performance", {})
369
+ # Minimalist Table
370
+ table = Table(box=None, show_header=False, padding=(0, 2))
371
+ table.add_row("[bold]Return[/]", f"[green]{perf.get('total_return')}[/]")
372
+ table.add_row("[bold]Sharpe[/]", f"{result.get('risk', {}).get('sharpe_ratio')}")
373
+ table.add_row("[bold]Equity[/]", f"{perf.get('final_equity')}")
374
+
375
+ chat_log.write(Panel(table, title=f"Backtest: {symbol}", border_style="#d97757"))
376
+ except Exception as e:
377
+ chat_log.write(f"[red]{e}[/red]")
378
+
379
+ async def handle_chat(self, req: Request, chat_log: RichLog, trace_log: Optional[RichLog]):
380
+ if not self.llm_router:
381
+ chat_log.write("[red]Setup required.[/red]")
1156
382
  return
383
+
384
+ chat_log.write("") # break
385
+
386
+ # System prompt to enforce thought signatures for Hack Club/Gemini compatibility
387
+ system_prompt = (
388
+ "You are Sigma, an advanced financial research assistant. "
389
+ "You are powered by various LLMs and have access to real-time financial tools. "
390
+ "You MUST use available tools for any question regarding stock prices, financials, market news, or analysis. "
391
+ "Never hallucinate financial data. Always fetch it using the provided tools. "
392
+ "CRITICAL INSTRUCTION: When calling any tool, you MUST provide the 'thought_signature' parameter. "
393
+ "This parameter should contain your internal reasoning for why you are calling that specific tool. "
394
+ "Do not output the thought signature in your final response, only use it in the tool call. "
395
+ "Format your final response in clean Markdown."
396
+ )
1157
397
 
1158
- self.is_processing = True
1159
- indicator = self.query_one("#sigma-indicator", SigmaIndicator)
1160
- tool_display = self.query_one("#tool-calls-display", ToolCallDisplay)
1161
- ticker_highlight = self.query_one("#ticker-highlight", TickerHighlight)
1162
-
1163
- # Clear ticker highlight and start sigma animation
1164
- ticker_highlight.update("")
1165
- indicator.set_active(True, mode="thinking")
398
+ messages = [
399
+ {"role": "system", "content": system_prompt},
400
+ {"role": "user", "content": req.original_query}
401
+ ]
1166
402
 
1167
403
  try:
1168
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
1169
- messages.extend(self.conversation)
1170
- messages.append({"role": "user", "content": query})
1171
-
1172
- all_tools = TOOLS + [BACKTEST_TOOL]
1173
-
1174
- async def on_tool(name: str, args: dict):
1175
- tool_display.add_tool_call(name)
1176
- if name == "run_backtest":
1177
- result = run_backtest(**args)
1178
- else:
1179
- result = execute_tool(name, args)
1180
- tool_display.complete_tool_call(name)
1181
- return result
1182
-
1183
- response = await self.llm.generate(messages, tools=all_tools, on_tool_call=on_tool)
1184
-
1185
- # Clear tool display after getting response
1186
- await asyncio.sleep(0.5) # Brief pause to show completion
1187
- tool_display.clear()
404
+ stream = await self.llm_router.chat(
405
+ messages,
406
+ tools=TOOL_REGISTRY.to_llm_format(),
407
+ on_tool_call=self.mk_tool_callback(trace_log)
408
+ )
1188
409
 
1189
- if response:
1190
- chat.write_assistant(response)
1191
- self.conversation.append({"role": "user", "content": query})
1192
- self.conversation.append({"role": "assistant", "content": response})
1193
- if len(self.conversation) > 20:
1194
- self.conversation = self.conversation[-20:]
410
+ if isinstance(stream, str):
411
+ chat_log.write(Markdown(stream))
1195
412
  else:
1196
- chat.write_error(f"[E{ErrorCode.RESPONSE_INVALID}] No response received")
413
+ acc = ""
414
+ async for chunk in stream:
415
+ acc += chunk
416
+ chat_log.write(Markdown(acc))
1197
417
 
1198
- except SigmaError as e:
1199
- tool_display.clear()
1200
- # Format SigmaError nicely
1201
- chat.write_error(f"[E{e.code}] {e.message}")
1202
- if e.details.get("hint"):
1203
- chat.write_system(f"[dim]Hint: {e.details['hint']}[/dim]")
1204
418
  except Exception as e:
1205
- tool_display.clear()
1206
- # Try to parse common API errors
1207
- error_str = str(e)
1208
- if "401" in error_str or "invalid" in error_str.lower() and "key" in error_str.lower():
1209
- chat.write_error(f"[E{ErrorCode.API_KEY_INVALID}] API key is invalid. Use /keys to update.")
1210
- elif "429" in error_str or "rate" in error_str.lower():
1211
- chat.write_error(f"[E{ErrorCode.API_KEY_RATE_LIMITED}] Rate limit hit. Wait a moment and try again.")
1212
- else:
1213
- chat.write_error(f"[E{ErrorCode.PROVIDER_ERROR}] {error_str[:200]}")
1214
- finally:
1215
- indicator.set_active(False)
1216
- self.is_processing = False
1217
- self.query_one("#prompt-input", Input).focus()
1218
-
1219
- def action_clear(self):
1220
- chat = self.query_one("#chat-log", ChatLog)
1221
- chat.clear()
1222
- self.conversation = []
1223
- chat.write_system("Cleared")
1224
-
1225
- def action_models(self):
1226
- self._show_models(self.query_one("#chat-log", ChatLog))
1227
-
1228
- def action_help_toggle(self):
1229
- """Toggle quick help panel."""
1230
- help_panel = self.query_one("#help-panel", Static)
1231
- if self.show_help:
1232
- help_panel.remove_class("visible")
1233
- help_panel.update("")
1234
- else:
1235
- help_panel.add_class("visible")
1236
- help_panel.update(Text.from_markup(
1237
- "[bold]Quick Commands:[/bold] /help /clear /keys /models /status /backtest "
1238
- "[bold]Shortcuts:[/bold] Tab=autocomplete Ctrl+L=clear Ctrl+M=models"
1239
- ))
1240
- self.show_help = not self.show_help
1241
-
1242
- def action_cancel(self):
1243
- if self.is_processing:
1244
- self.is_processing = False
1245
- tool_display = self.query_one("#tool-calls-display", ToolCallDisplay)
1246
- tool_display.clear()
1247
-
419
+ chat_log.write(f"[red]LLM Error: {e}[/red]")
420
+
421
+ def mk_tool_callback(self, trace_log: Optional[RichLog]):
422
+ async def callback(name: str, args: dict):
423
+ if trace_log:
424
+ trace_log.write(f"[yellow]➜ {name}[/yellow]")
425
+ try:
426
+ res = await TOOL_REGISTRY.execute(name, args)
427
+ if trace_log:
428
+ trace_log.write(f"[green]✔[/green]")
429
+ return res
430
+ except Exception as e:
431
+ if trace_log:
432
+ trace_log.write(f"[red]✖ {e}[/red]")
433
+ raise
434
+ return callback
435
+
436
+ def action_toggle_sidebar(self):
437
+ try:
438
+ sidebar = self.query_one("#sidebar")
439
+ sidebar.set_class(not sidebar.has_class("visible"), "visible")
440
+ except:
441
+ pass
1248
442
 
1249
443
  def launch():
1250
- """Launch Sigma."""
1251
- SigmaApp().run()
1252
-
1253
-
1254
- if __name__ == "__main__":
1255
- launch()
444
+ app = SigmaApp()
445
+ app.run()