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/__init__.py +4 -5
- sigma/analytics/__init__.py +11 -9
- sigma/app.py +384 -1125
- sigma/backtest/__init__.py +2 -0
- sigma/backtest/service.py +116 -0
- sigma/charts.py +2 -2
- sigma/cli.py +15 -13
- sigma/comparison.py +2 -2
- sigma/config.py +25 -12
- sigma/core/command_router.py +93 -0
- sigma/llm/__init__.py +3 -0
- sigma/llm/providers/anthropic_provider.py +196 -0
- sigma/llm/providers/base.py +29 -0
- sigma/llm/providers/google_provider.py +197 -0
- sigma/llm/providers/ollama_provider.py +156 -0
- sigma/llm/providers/openai_provider.py +168 -0
- sigma/llm/providers/sigma_cloud_provider.py +57 -0
- sigma/llm/rate_limit.py +40 -0
- sigma/llm/registry.py +66 -0
- sigma/llm/router.py +122 -0
- sigma/setup_agent.py +188 -0
- sigma/tools/__init__.py +23 -0
- sigma/tools/adapter.py +38 -0
- sigma/{tools.py → tools/library.py} +593 -1
- sigma/tools/registry.py +108 -0
- sigma/utils/extraction.py +83 -0
- sigma_terminal-3.5.0.dist-info/METADATA +184 -0
- sigma_terminal-3.5.0.dist-info/RECORD +46 -0
- sigma/llm.py +0 -786
- sigma/setup.py +0 -440
- sigma_terminal-3.4.0.dist-info/METADATA +0 -264
- sigma_terminal-3.4.0.dist-info/RECORD +0 -30
- /sigma/{backtest.py → backtest/simple_engine.py} +0 -0
- {sigma_terminal-3.4.0.dist-info → sigma_terminal-3.5.0.dist-info}/WHEEL +0 -0
- {sigma_terminal-3.4.0.dist-info → sigma_terminal-3.5.0.dist-info}/entry_points.txt +0 -0
- {sigma_terminal-3.4.0.dist-info → sigma_terminal-3.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import AsyncIterator, Dict, Any, Optional
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
class BacktestService:
|
|
10
|
+
def __init__(self, data_dir: str = "~/.sigma/lean_data"):
|
|
11
|
+
self.data_dir = os.path.expanduser(data_dir)
|
|
12
|
+
self.lean_cli = shutil.which("lean") or "lean"
|
|
13
|
+
|
|
14
|
+
async def run_lean(self, algorithm_file: str, project_dir: str) -> AsyncIterator[Dict[str, Any]]:
|
|
15
|
+
"""Run LEAN backtest and stream results."""
|
|
16
|
+
|
|
17
|
+
# Validate project structure
|
|
18
|
+
# lean backtest "Project Name"
|
|
19
|
+
|
|
20
|
+
# We assume the project is already "init-ed" or valid for LEAN.
|
|
21
|
+
# But for ad-hoc strategies, we might need a temp project.
|
|
22
|
+
|
|
23
|
+
project_name = os.path.basename(project_dir)
|
|
24
|
+
parent_dir = os.path.dirname(project_dir)
|
|
25
|
+
|
|
26
|
+
cmd = [
|
|
27
|
+
self.lean_cli, "backtest", project_name,
|
|
28
|
+
"--output", "backtest-result",
|
|
29
|
+
"--verbose"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# LEAN runs in CWD usually or expects config.
|
|
33
|
+
# We should run execution in the parent dir of the project so "lean backtest Project" works.
|
|
34
|
+
|
|
35
|
+
process = await asyncio.create_subprocess_exec(
|
|
36
|
+
*cmd,
|
|
37
|
+
cwd=parent_dir,
|
|
38
|
+
stdout=asyncio.subprocess.PIPE,
|
|
39
|
+
stderr=asyncio.subprocess.PIPE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Stream output
|
|
43
|
+
yield {"type": "status", "message": "Starting LEAN engine..."}
|
|
44
|
+
|
|
45
|
+
if process.stdout:
|
|
46
|
+
while True:
|
|
47
|
+
line = await process.stdout.readline()
|
|
48
|
+
if not line:
|
|
49
|
+
break
|
|
50
|
+
|
|
51
|
+
line_str = line.decode().strip()
|
|
52
|
+
if not line_str: continue
|
|
53
|
+
|
|
54
|
+
# Parse typical LEAN logs if possible
|
|
55
|
+
if "Error" in line_str:
|
|
56
|
+
yield {"type": "error", "message": line_str}
|
|
57
|
+
else:
|
|
58
|
+
yield {"type": "log", "message": line_str}
|
|
59
|
+
|
|
60
|
+
# Trying to detect progress or stats in logs
|
|
61
|
+
# LEAN logs: "STATISTICS:: ..."
|
|
62
|
+
|
|
63
|
+
await process.wait()
|
|
64
|
+
|
|
65
|
+
if process.returncode != 0:
|
|
66
|
+
err_msg = "Unknown error"
|
|
67
|
+
if process.stderr:
|
|
68
|
+
stderr_data = await process.stderr.read()
|
|
69
|
+
err_msg = stderr_data.decode()
|
|
70
|
+
yield {"type": "error", "message": f"LEAN failed: {err_msg}"}
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Parse results
|
|
74
|
+
json_file = os.path.join(project_dir, "backtest-result", f"{project_name}.json") # Filename varies usually?
|
|
75
|
+
# Actually lean backtest creates a file in the output dir with a name.
|
|
76
|
+
# We need to find the latest json in that dir.
|
|
77
|
+
|
|
78
|
+
result_dir = os.path.join(project_dir, "backtest-result")
|
|
79
|
+
if os.path.exists(result_dir):
|
|
80
|
+
files = [f for f in os.listdir(result_dir) if f.endswith(".json")]
|
|
81
|
+
if files:
|
|
82
|
+
# Get newest
|
|
83
|
+
latest = max([os.path.join(result_dir, f) for f in files], key=os.path.getctime)
|
|
84
|
+
try:
|
|
85
|
+
with open(latest, 'r') as f:
|
|
86
|
+
data = json.load(f)
|
|
87
|
+
yield {"type": "result", "data": data}
|
|
88
|
+
except Exception as e:
|
|
89
|
+
yield {"type": "error", "message": f"Failed to parse results: {e}"}
|
|
90
|
+
else:
|
|
91
|
+
yield {"type": "error", "message": "No result JSON found."}
|
|
92
|
+
else:
|
|
93
|
+
yield {"type": "error", "message": "No result directory found."}
|
|
94
|
+
|
|
95
|
+
async def create_project(self, name: str, code: str) -> str:
|
|
96
|
+
"""Create a LEAN project with the given code."""
|
|
97
|
+
# Typically ~/.sigma/lean_projects/Name
|
|
98
|
+
projects_dir = os.path.expanduser("~/.sigma/lean_projects")
|
|
99
|
+
os.makedirs(projects_dir, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
project_path = os.path.join(projects_dir, name)
|
|
102
|
+
os.makedirs(project_path, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
# content
|
|
105
|
+
# main.py
|
|
106
|
+
with open(os.path.join(project_path, "main.py"), "w") as f:
|
|
107
|
+
f.write(code)
|
|
108
|
+
|
|
109
|
+
# config.json needed?
|
|
110
|
+
# lean init usually creates `lean.json` in root.
|
|
111
|
+
# We assume the user has a workspace or we set one up in ~/.sigma/lean_data?
|
|
112
|
+
# The service should ensure a workspace exists.
|
|
113
|
+
|
|
114
|
+
return project_path
|
|
115
|
+
|
|
116
|
+
SERVICE = BacktestService()
|
sigma/charts.py
CHANGED
|
@@ -234,8 +234,8 @@ def create_technical_chart(
|
|
|
234
234
|
go.Scatter(x=data.index, y=rsi, name="RSI", line=dict(color=SIGMA_THEME["accent"])),
|
|
235
235
|
row=row, col=1
|
|
236
236
|
)
|
|
237
|
-
fig.add_hline(y=70, line_dash="dash", line_color=SIGMA_THEME["negative"], row=row, col=1)
|
|
238
|
-
fig.add_hline(y=30, line_dash="dash", line_color=SIGMA_THEME["positive"], row=row, col=1)
|
|
237
|
+
fig.add_hline(y=70, line_dash="dash", line_color=SIGMA_THEME["negative"], row=row, col=1) # type: ignore
|
|
238
|
+
fig.add_hline(y=30, line_dash="dash", line_color=SIGMA_THEME["positive"], row=row, col=1) # type: ignore
|
|
239
239
|
fig.update_yaxes(range=[0, 100], row=row, col=1)
|
|
240
240
|
row += 1
|
|
241
241
|
|
sigma/cli.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""CLI entry point for Sigma v3.
|
|
1
|
+
"""CLI entry point for Sigma v3.5.0."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
@@ -17,7 +17,7 @@ from .config import (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
__version__ = "3.
|
|
20
|
+
__version__ = "3.5.0"
|
|
21
21
|
|
|
22
22
|
console = Console()
|
|
23
23
|
|
|
@@ -32,7 +32,7 @@ def show_banner():
|
|
|
32
32
|
[bold #3b82f6]███████║██║╚██████╔╝██║ ╚═╝ ██║██║ ██║[/bold #3b82f6]
|
|
33
33
|
[bold #1d4ed8]╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝[/bold #1d4ed8]
|
|
34
34
|
|
|
35
|
-
[dim]v3.
|
|
35
|
+
[dim]v3.5.0[/dim] [bold cyan]σ[/bold cyan] [bold]Finance Research Agent[/bold]
|
|
36
36
|
"""
|
|
37
37
|
console.print(banner)
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ def main():
|
|
|
41
41
|
"""Main CLI entry point."""
|
|
42
42
|
parser = argparse.ArgumentParser(
|
|
43
43
|
prog="sigma",
|
|
44
|
-
description="Sigma v3.
|
|
44
|
+
description="Sigma v3.5.0 - Finance Research Agent",
|
|
45
45
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
46
46
|
epilog="""
|
|
47
47
|
Examples:
|
|
@@ -129,7 +129,7 @@ Examples:
|
|
|
129
129
|
return 0
|
|
130
130
|
|
|
131
131
|
if args.setup:
|
|
132
|
-
from .
|
|
132
|
+
from .setup_agent import run_setup
|
|
133
133
|
result = run_setup()
|
|
134
134
|
if result:
|
|
135
135
|
mark_first_run_complete()
|
|
@@ -143,12 +143,12 @@ Examples:
|
|
|
143
143
|
if is_first_run():
|
|
144
144
|
console.print("\n[bold cyan]σ[/bold cyan] [bold]Welcome to Sigma![/bold]")
|
|
145
145
|
console.print("[dim]First time setup detected. Launching setup wizard...[/dim]\n")
|
|
146
|
-
from .
|
|
146
|
+
from .setup_agent import run_setup
|
|
147
147
|
result = run_setup()
|
|
148
148
|
mark_first_run_complete() # Always mark complete to not ask again
|
|
149
149
|
|
|
150
150
|
if result:
|
|
151
|
-
console.print("\n[bold green]
|
|
151
|
+
console.print("\n[bold green]Setup complete![/bold green]")
|
|
152
152
|
console.print("[dim]Launching Sigma...[/dim]\n")
|
|
153
153
|
import time
|
|
154
154
|
time.sleep(1) # Brief pause for user to see message
|
|
@@ -238,15 +238,15 @@ Examples:
|
|
|
238
238
|
def handle_ask(query: str) -> int:
|
|
239
239
|
"""Handle ask command."""
|
|
240
240
|
import asyncio
|
|
241
|
-
from .llm import
|
|
242
|
-
from .tools import
|
|
241
|
+
from .llm import get_router
|
|
242
|
+
from .tools import get_tools_for_llm, execute_tool
|
|
243
243
|
|
|
244
244
|
settings = get_settings()
|
|
245
245
|
|
|
246
|
-
console.print(f"\n[dim]Using
|
|
246
|
+
console.print(f"\n[dim]Using model: {settings.default_model}[/dim]\n")
|
|
247
247
|
|
|
248
248
|
try:
|
|
249
|
-
|
|
249
|
+
router = get_router(settings)
|
|
250
250
|
|
|
251
251
|
async def run_query():
|
|
252
252
|
"""Run the query asynchronously."""
|
|
@@ -260,13 +260,15 @@ def handle_ask(query: str) -> int:
|
|
|
260
260
|
console.print(f"[dim]Executing: {name}[/dim]")
|
|
261
261
|
return execute_tool(name, args)
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
# Using stream=False for CLI quick question to get full text at once
|
|
264
|
+
response = await router.chat(messages, tools=get_tools_for_llm(), on_tool_call=handle_tool, stream=False)
|
|
264
265
|
return response
|
|
265
266
|
|
|
266
267
|
with console.status("[bold blue]σ analyzing...[/bold blue]"):
|
|
267
268
|
response = asyncio.run(run_query())
|
|
268
269
|
|
|
269
|
-
|
|
270
|
+
from rich.markdown import Markdown
|
|
271
|
+
console.print(Panel(Markdown(str(response)), title="[bold cyan]σ Sigma[/bold cyan]"))
|
|
270
272
|
return 0
|
|
271
273
|
|
|
272
274
|
except Exception as e:
|
sigma/comparison.py
CHANGED
|
@@ -219,7 +219,7 @@ class ComparisonEngine:
|
|
|
219
219
|
|
|
220
220
|
# Momentum score (recent performance relative to history)
|
|
221
221
|
if len(returns) > 252:
|
|
222
|
-
recent_return = (1 + returns.iloc[-63:]).prod() - 1 # 3 months
|
|
222
|
+
recent_return = float((1 + returns.iloc[-63:]).prod()) - 1 # 3 months # type: ignore
|
|
223
223
|
historical_vol = returns.iloc[:-63].std()
|
|
224
224
|
momentum_score = recent_return / (historical_vol * np.sqrt(63)) if historical_vol > 0 else 0
|
|
225
225
|
else:
|
|
@@ -236,7 +236,7 @@ class ComparisonEngine:
|
|
|
236
236
|
if len(returns) > 252:
|
|
237
237
|
monthly_returns = returns.resample('M').apply(lambda x: (1 + x).prod() - 1)
|
|
238
238
|
if len(monthly_returns) > 2:
|
|
239
|
-
trend_persistence = monthly_returns.autocorr(lag=1)
|
|
239
|
+
trend_persistence = monthly_returns.autocorr(lag=1) # type: ignore
|
|
240
240
|
else:
|
|
241
241
|
trend_persistence = 0
|
|
242
242
|
else:
|
sigma/config.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Configuration management for Sigma v3.
|
|
1
|
+
"""Configuration management for Sigma v3.5.0."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import shutil
|
|
@@ -11,7 +11,7 @@ from pydantic import Field
|
|
|
11
11
|
from pydantic_settings import BaseSettings
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
__version__ = "3.
|
|
14
|
+
__version__ = "3.5.0"
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class ErrorCode(IntEnum):
|
|
@@ -65,6 +65,7 @@ class SigmaError(Exception):
|
|
|
65
65
|
|
|
66
66
|
class LLMProvider(str, Enum):
|
|
67
67
|
"""Supported LLM providers."""
|
|
68
|
+
SIGMA_CLOUD = "sigma_cloud" # Hack Club / Default
|
|
68
69
|
GOOGLE = "google"
|
|
69
70
|
OPENAI = "openai"
|
|
70
71
|
ANTHROPIC = "anthropic"
|
|
@@ -73,16 +74,24 @@ class LLMProvider(str, Enum):
|
|
|
73
74
|
OLLAMA = "ollama"
|
|
74
75
|
|
|
75
76
|
|
|
76
|
-
# Available models per provider (Feb 2026 -
|
|
77
|
+
# Available models per provider (Feb 2026 - REAL API NAMES)
|
|
77
78
|
AVAILABLE_MODELS = {
|
|
79
|
+
"sigma_cloud": [
|
|
80
|
+
"moonshotai/kimi-k2.5", # Default via Hack Club
|
|
81
|
+
"gpt-4o",
|
|
82
|
+
"claude-3-5-sonnet-20240620"
|
|
83
|
+
],
|
|
78
84
|
"google": [
|
|
79
|
-
"gemini-3-flash",
|
|
80
|
-
"gemini-3-pro",
|
|
85
|
+
"gemini-3-flash-preview", # Fast multimodal, Pro-level at Flash speed
|
|
86
|
+
"gemini-3-pro-preview", # Multimodal reasoning (1M tokens)
|
|
87
|
+
"gemini-3-pro-image-preview", # Image+text focus (65K tokens)
|
|
81
88
|
],
|
|
82
89
|
"openai": [
|
|
83
|
-
"gpt-5", #
|
|
84
|
-
"gpt-5-mini", #
|
|
85
|
-
"
|
|
90
|
+
"gpt-5", # Flagship general/agentic, multimodal (256K)
|
|
91
|
+
"gpt-5-mini", # Cost-efficient variant (256K)
|
|
92
|
+
"gpt-5.2", # Enterprise knowledge work, advanced reasoning
|
|
93
|
+
"gpt-5-nano", # Ultra-cheap/lightweight
|
|
94
|
+
"o3", # Advanced reasoning
|
|
86
95
|
"o3-mini", # Fast reasoning
|
|
87
96
|
],
|
|
88
97
|
"anthropic": [
|
|
@@ -309,18 +318,20 @@ class Settings(BaseSettings):
|
|
|
309
318
|
"""Application settings."""
|
|
310
319
|
|
|
311
320
|
# Provider settings
|
|
312
|
-
default_provider: LLMProvider = LLMProvider.
|
|
313
|
-
default_model: str = Field(default="
|
|
321
|
+
default_provider: LLMProvider = LLMProvider.SIGMA_CLOUD
|
|
322
|
+
default_model: str = Field(default="gpt-4o", alias="DEFAULT_MODEL")
|
|
314
323
|
|
|
315
324
|
# LLM API Keys
|
|
325
|
+
sigma_cloud_api_key: Optional[str] = Field(default=None, alias="SIGMA_CLOUD_API_KEY")
|
|
316
326
|
google_api_key: Optional[str] = Field(default=None, alias="GOOGLE_API_KEY")
|
|
317
327
|
openai_api_key: Optional[str] = Field(default=None, alias="OPENAI_API_KEY")
|
|
318
328
|
anthropic_api_key: Optional[str] = Field(default=None, alias="ANTHROPIC_API_KEY")
|
|
319
329
|
groq_api_key: Optional[str] = Field(default=None, alias="GROQ_API_KEY")
|
|
320
330
|
xai_api_key: Optional[str] = Field(default=None, alias="XAI_API_KEY")
|
|
321
331
|
|
|
322
|
-
# Model settings -
|
|
323
|
-
|
|
332
|
+
# Model settings - REAL API NAMES (Feb 2026)
|
|
333
|
+
sigma_cloud_model: str = "moonshotai/kimi-k2.5" # Should map to OpenAI-compatible endpoint
|
|
334
|
+
google_model: str = "gemini-3-flash-preview"
|
|
324
335
|
openai_model: str = "gpt-5"
|
|
325
336
|
anthropic_model: str = "claude-sonnet-4-20250514"
|
|
326
337
|
groq_model: str = "llama-3.3-70b-versatile"
|
|
@@ -348,6 +359,7 @@ class Settings(BaseSettings):
|
|
|
348
359
|
def get_api_key(self, provider: LLMProvider) -> Optional[str]:
|
|
349
360
|
"""Get API key for a provider."""
|
|
350
361
|
key_map = {
|
|
362
|
+
LLMProvider.SIGMA_CLOUD: self.sigma_cloud_api_key,
|
|
351
363
|
LLMProvider.GOOGLE: self.google_api_key,
|
|
352
364
|
LLMProvider.OPENAI: self.openai_api_key,
|
|
353
365
|
LLMProvider.ANTHROPIC: self.anthropic_api_key,
|
|
@@ -360,6 +372,7 @@ class Settings(BaseSettings):
|
|
|
360
372
|
def get_model(self, provider: LLMProvider) -> str:
|
|
361
373
|
"""Get model for a provider."""
|
|
362
374
|
model_map = {
|
|
375
|
+
LLMProvider.SIGMA_CLOUD: self.sigma_cloud_model,
|
|
363
376
|
LLMProvider.GOOGLE: self.google_model,
|
|
364
377
|
LLMProvider.OPENAI: self.openai_model,
|
|
365
378
|
LLMProvider.ANTHROPIC: self.anthropic_model,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from .intent import IntentParser
|
|
5
|
+
from ..utils.extraction import extract_tickers, extract_timeframe
|
|
6
|
+
|
|
7
|
+
class Request(BaseModel):
|
|
8
|
+
action: str
|
|
9
|
+
tickers: List[str]
|
|
10
|
+
timeframe: str
|
|
11
|
+
output_mode: str = "report" # report, memo, quant, summary, etc.
|
|
12
|
+
original_query: str
|
|
13
|
+
is_command: bool = False
|
|
14
|
+
details: Dict[str, Any] = {}
|
|
15
|
+
|
|
16
|
+
class CommandRouter:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.intent_parser = IntentParser()
|
|
19
|
+
|
|
20
|
+
def parse(self, query: str) -> Request:
|
|
21
|
+
stripped = query.strip()
|
|
22
|
+
|
|
23
|
+
# Explicit commands
|
|
24
|
+
if stripped.startswith("/"):
|
|
25
|
+
parts = stripped.split(maxsplit=1)
|
|
26
|
+
cmd = parts[0][1:].lower() # remove /
|
|
27
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
28
|
+
|
|
29
|
+
# Map commands to standard request structures
|
|
30
|
+
if cmd == "backtest":
|
|
31
|
+
tickers = extract_tickers(args)
|
|
32
|
+
return Request(
|
|
33
|
+
action="backtest",
|
|
34
|
+
tickers=tickers,
|
|
35
|
+
timeframe="default",
|
|
36
|
+
output_mode="quant",
|
|
37
|
+
original_query=query,
|
|
38
|
+
is_command=True
|
|
39
|
+
)
|
|
40
|
+
elif cmd == "chart":
|
|
41
|
+
tickers = extract_tickers(args)
|
|
42
|
+
return Request(
|
|
43
|
+
action="chart",
|
|
44
|
+
tickers=tickers,
|
|
45
|
+
timeframe="default",
|
|
46
|
+
output_mode="chart",
|
|
47
|
+
original_query=query,
|
|
48
|
+
is_command=True
|
|
49
|
+
)
|
|
50
|
+
elif cmd == "model":
|
|
51
|
+
# Handled by UI/Engine specifically to switch model?
|
|
52
|
+
return Request(
|
|
53
|
+
action="config_model",
|
|
54
|
+
tickers=[],
|
|
55
|
+
timeframe="",
|
|
56
|
+
output_mode="system",
|
|
57
|
+
original_query=query,
|
|
58
|
+
is_command=True,
|
|
59
|
+
details={"model": args.strip()}
|
|
60
|
+
)
|
|
61
|
+
elif cmd == "setup":
|
|
62
|
+
return Request(action="setup", tickers=[], timeframe="", output_mode="system", original_query=query, is_command=True)
|
|
63
|
+
|
|
64
|
+
# Natural Language
|
|
65
|
+
tickers = extract_tickers(query)
|
|
66
|
+
tf_desc, start, end = extract_timeframe(query)
|
|
67
|
+
|
|
68
|
+
# Simple heuristic for output mode
|
|
69
|
+
output_mode = "report"
|
|
70
|
+
if "memo" in query.lower(): output_mode = "memo"
|
|
71
|
+
if "summary" in query.lower(): output_mode = "summary"
|
|
72
|
+
if "backtest" in query.lower() or "quant" in query.lower(): output_mode = "quant"
|
|
73
|
+
|
|
74
|
+
# Use existing IntentParser for heavier lifting if needed
|
|
75
|
+
# But Request object normalizes it for Engine
|
|
76
|
+
# Here we just do extraction logic
|
|
77
|
+
|
|
78
|
+
# Intent parser might give us plan, but we want a unified Request struct first?
|
|
79
|
+
# Actually Engine uses IntentParser.
|
|
80
|
+
# Let's wrap IntentParsing into "action" determination.
|
|
81
|
+
|
|
82
|
+
plan = self.intent_parser.parse(query)
|
|
83
|
+
action = plan.deliverable if plan else "analysis"
|
|
84
|
+
|
|
85
|
+
return Request(
|
|
86
|
+
action=action,
|
|
87
|
+
tickers=tickers,
|
|
88
|
+
timeframe=tf_desc,
|
|
89
|
+
output_mode=output_mode,
|
|
90
|
+
original_query=query,
|
|
91
|
+
is_command=False,
|
|
92
|
+
details={"plan": plan}
|
|
93
|
+
)
|
sigma/llm/__init__.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional, Union, AsyncIterator
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .base import BaseLLM
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class AnthropicProvider(BaseLLM):
|
|
10
|
+
"""Anthropic Claude client."""
|
|
11
|
+
|
|
12
|
+
provider_name = "anthropic"
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str, rate_limiter=None, base_url: Optional[str] = None):
|
|
15
|
+
super().__init__(rate_limiter)
|
|
16
|
+
from anthropic import AsyncAnthropic
|
|
17
|
+
self.client = AsyncAnthropic(api_key=api_key, base_url=base_url)
|
|
18
|
+
|
|
19
|
+
async def generate(
|
|
20
|
+
self,
|
|
21
|
+
messages: List[Dict[str, str]],
|
|
22
|
+
model: str,
|
|
23
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
24
|
+
on_tool_call: Optional[Callable] = None,
|
|
25
|
+
stream: bool = True,
|
|
26
|
+
json_mode: bool = False,
|
|
27
|
+
) -> Union[str, AsyncIterator[str]]:
|
|
28
|
+
await self._wait_for_rate_limit()
|
|
29
|
+
|
|
30
|
+
# Format messages for Anthropic (separate system message)
|
|
31
|
+
system_prompt = ""
|
|
32
|
+
filtered_messages = []
|
|
33
|
+
for msg in messages:
|
|
34
|
+
if msg["role"] == "system":
|
|
35
|
+
system_prompt = msg["content"]
|
|
36
|
+
else:
|
|
37
|
+
filtered_messages.append(msg)
|
|
38
|
+
|
|
39
|
+
kwargs = {
|
|
40
|
+
"model": model,
|
|
41
|
+
"max_tokens": 4096,
|
|
42
|
+
"messages": filtered_messages,
|
|
43
|
+
"system": system_prompt,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if tools:
|
|
47
|
+
kwargs["tools"] = tools
|
|
48
|
+
|
|
49
|
+
if stream:
|
|
50
|
+
return self._stream_response(kwargs, on_tool_call, tools, messages) # Pass original messages for recursion
|
|
51
|
+
else:
|
|
52
|
+
return await self._block_response(kwargs, on_tool_call, tools, messages)
|
|
53
|
+
|
|
54
|
+
async def _block_response(self, kwargs, on_tool_call, tools, messages):
|
|
55
|
+
response = await self.client.messages.create(**kwargs)
|
|
56
|
+
|
|
57
|
+
# Check for tool usage
|
|
58
|
+
if response.stop_reason == "tool_use" and on_tool_call:
|
|
59
|
+
tool_results = []
|
|
60
|
+
assistant_content = []
|
|
61
|
+
|
|
62
|
+
# Construct assistant message parts
|
|
63
|
+
for block in response.content:
|
|
64
|
+
if block.type == "text":
|
|
65
|
+
assistant_content.append({"type": "text", "text": block.text})
|
|
66
|
+
elif block.type == "tool_use":
|
|
67
|
+
assistant_content.append({
|
|
68
|
+
"type": "tool_use",
|
|
69
|
+
"id": block.id,
|
|
70
|
+
"name": block.name,
|
|
71
|
+
"input": block.input
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
# Execute tool
|
|
75
|
+
try:
|
|
76
|
+
result = await on_tool_call(block.name, block.input)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
result = {"error": str(e)}
|
|
79
|
+
|
|
80
|
+
tool_results.append({
|
|
81
|
+
"type": "tool_result",
|
|
82
|
+
"tool_use_id": block.id,
|
|
83
|
+
"content": json.dumps(result, default=str)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# Append exchange to history
|
|
87
|
+
new_messages = messages + [
|
|
88
|
+
{"role": "assistant", "content": assistant_content},
|
|
89
|
+
{"role": "user", "content": tool_results}
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
# Recurse
|
|
93
|
+
# Re-format for next call
|
|
94
|
+
next_kwargs = kwargs.copy()
|
|
95
|
+
next_system = ""
|
|
96
|
+
next_filtered = []
|
|
97
|
+
for msg in new_messages:
|
|
98
|
+
if isinstance(msg.get("content"), str): # Basic text message
|
|
99
|
+
if msg["role"] == "system":
|
|
100
|
+
next_system = msg["content"]
|
|
101
|
+
else:
|
|
102
|
+
next_filtered.append(msg)
|
|
103
|
+
else: # Complex content
|
|
104
|
+
next_filtered.append(msg)
|
|
105
|
+
|
|
106
|
+
next_kwargs["messages"] = next_filtered
|
|
107
|
+
next_kwargs["system"] = next_system
|
|
108
|
+
|
|
109
|
+
return await self._block_response(next_kwargs, on_tool_call, tools, new_messages)
|
|
110
|
+
|
|
111
|
+
return response.content[0].text if response.content else ""
|
|
112
|
+
|
|
113
|
+
async def _stream_response(self, kwargs, on_tool_call, tools, messages) -> AsyncIterator[str]:
|
|
114
|
+
async with self.client.messages.stream(**kwargs) as stream:
|
|
115
|
+
current_tool_use: Dict[str, Any] = {}
|
|
116
|
+
current_text = ""
|
|
117
|
+
tool_uses = []
|
|
118
|
+
|
|
119
|
+
async for event in stream:
|
|
120
|
+
if event.type == "content_block_delta":
|
|
121
|
+
if event.delta.type == "text_delta":
|
|
122
|
+
yield event.delta.text
|
|
123
|
+
current_text += event.delta.text
|
|
124
|
+
elif event.delta.type == "input_json_delta":
|
|
125
|
+
# Accumulate JSON
|
|
126
|
+
current_tool_use["input_json"] = current_tool_use.get("input_json", "") + event.delta.partial_json
|
|
127
|
+
|
|
128
|
+
elif event.type == "content_block_start":
|
|
129
|
+
if event.content_block.type == "tool_use":
|
|
130
|
+
current_tool_use = {
|
|
131
|
+
"name": event.content_block.name,
|
|
132
|
+
"id": event.content_block.id,
|
|
133
|
+
"input_json": ""
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
elif event.type == "content_block_stop":
|
|
137
|
+
if current_tool_use:
|
|
138
|
+
try:
|
|
139
|
+
current_tool_use["input"] = json.loads(current_tool_use["input_json"])
|
|
140
|
+
except:
|
|
141
|
+
current_tool_use["input"] = {}
|
|
142
|
+
tool_uses.append(current_tool_use)
|
|
143
|
+
current_tool_use = {}
|
|
144
|
+
|
|
145
|
+
elif event.type == "message_stop":
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# If tools were used, execute and recurse
|
|
149
|
+
if tool_uses and on_tool_call:
|
|
150
|
+
# Reconstruct assistant message
|
|
151
|
+
assistant_content = []
|
|
152
|
+
if current_text:
|
|
153
|
+
assistant_content.append({"type": "text", "text": current_text})
|
|
154
|
+
|
|
155
|
+
tool_results = []
|
|
156
|
+
for tu in tool_uses:
|
|
157
|
+
assistant_content.append({
|
|
158
|
+
"type": "tool_use",
|
|
159
|
+
"id": tu["id"],
|
|
160
|
+
"name": tu["name"],
|
|
161
|
+
"input": tu["input"]
|
|
162
|
+
})
|
|
163
|
+
try:
|
|
164
|
+
res = await on_tool_call(tu["name"], tu["input"])
|
|
165
|
+
except Exception as e:
|
|
166
|
+
res = {"error": str(e)}
|
|
167
|
+
|
|
168
|
+
tool_results.append({
|
|
169
|
+
"type": "tool_result",
|
|
170
|
+
"tool_use_id": tu["id"],
|
|
171
|
+
"content": json.dumps(res, default=str)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
new_messages = messages + [
|
|
175
|
+
{"role": "assistant", "content": assistant_content},
|
|
176
|
+
{"role": "user", "content": tool_results}
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
# Prepare next call
|
|
180
|
+
next_kwargs = kwargs.copy()
|
|
181
|
+
next_system = ""
|
|
182
|
+
next_filtered = []
|
|
183
|
+
for msg in new_messages:
|
|
184
|
+
if isinstance(msg.get("content"), str):
|
|
185
|
+
if msg["role"] == "system":
|
|
186
|
+
next_system = msg["content"]
|
|
187
|
+
else:
|
|
188
|
+
next_filtered.append(msg)
|
|
189
|
+
else:
|
|
190
|
+
next_filtered.append(msg)
|
|
191
|
+
|
|
192
|
+
next_kwargs["messages"] = next_filtered
|
|
193
|
+
next_kwargs["system"] = next_system
|
|
194
|
+
|
|
195
|
+
async for chunk in self._stream_response(next_kwargs, on_tool_call, tools, new_messages):
|
|
196
|
+
yield chunk
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
3
|
+
from ..rate_limit import RateLimiter
|
|
4
|
+
|
|
5
|
+
class BaseLLM(ABC):
|
|
6
|
+
"""Base class for LLM clients."""
|
|
7
|
+
|
|
8
|
+
provider_name: str = "base"
|
|
9
|
+
|
|
10
|
+
def __init__(self, rate_limiter: Optional[RateLimiter] = None):
|
|
11
|
+
self.rate_limiter = rate_limiter
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def generate(
|
|
15
|
+
self,
|
|
16
|
+
messages: List[Dict[str, str]],
|
|
17
|
+
model: str,
|
|
18
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
19
|
+
on_tool_call: Optional[Callable] = None,
|
|
20
|
+
stream: bool = True,
|
|
21
|
+
json_mode: bool = False,
|
|
22
|
+
) -> Union[str, Any]:
|
|
23
|
+
"""Generate a response."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
async def _wait_for_rate_limit(self):
|
|
27
|
+
"""Apply rate limiting."""
|
|
28
|
+
if self.rate_limiter:
|
|
29
|
+
await self.rate_limiter.wait()
|