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