Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Providers (admin-switchable, no redeploy): - DeepSeek and any OpenAI-compatible endpoint (OpenRouter, Together, Groq, local Ollama) via a generic Chat Completions adapter + base_url - xAI Grok with Live Search (search_parameters web+X, citations) — grounded tier alongside OpenAI and Gemini - DeepSeek / generic compatible endpoints are ungrounded (no web search); UI shows an amber warning and labels each provider's grounding - Optional env fallbacks DEEPSEEK_API_KEY / XAI_API_KEY UI: replace native <select> (unstyleable white popup on Windows) with a custom dark Dropdown component everywhere — sentiment provider, scanner filters, market sort, indicators, admin universe, user role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
|
|||||||
openai_model: str = "gpt-4o-mini"
|
openai_model: str = "gpt-4o-mini"
|
||||||
openai_sentiment_batch_size: int = 5
|
openai_sentiment_batch_size: int = 5
|
||||||
|
|
||||||
|
# Sentiment Provider — DeepSeek / xAI (OpenAI-compatible; optional env fallback)
|
||||||
|
deepseek_api_key: str = ""
|
||||||
|
xai_api_key: str = ""
|
||||||
|
|
||||||
# Fundamentals Provider — Financial Modeling Prep
|
# Fundamentals Provider — Financial Modeling Prep
|
||||||
fmp_api_key: str = ""
|
fmp_api_key: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Sentiment provider for any OpenAI-compatible Chat Completions endpoint.
|
||||||
|
|
||||||
|
Covers DeepSeek, OpenRouter, Together, Groq, Mistral, local Ollama, etc. — any
|
||||||
|
service exposing the OpenAI Chat Completions API at a custom base_url.
|
||||||
|
|
||||||
|
NOTE: Unlike the OpenAI Responses provider and Gemini, this path has NO web
|
||||||
|
search grounding. Sentiment reflects the model's training knowledge, not live
|
||||||
|
news. Cheap, but not real-time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.exceptions import ProviderError, RateLimitError
|
||||||
|
from app.providers.protocol import SentimentData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "")
|
||||||
|
|
||||||
|
_SENTIMENT_PROMPT = """\
|
||||||
|
Assess the CURRENT market sentiment for the stock ticker {ticker} based on your \
|
||||||
|
knowledge of the company, its sector, and recent developments you are aware of.
|
||||||
|
|
||||||
|
Respond ONLY with a JSON object in this exact format (no markdown, no extra text):
|
||||||
|
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation>"}}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- classification must be exactly one of: bullish, bearish, neutral
|
||||||
|
- confidence must be an integer from 0 to 100
|
||||||
|
- reasoning should be a brief one-sentence explanation
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SENTIMENT_PROMPT_SEARCH = """\
|
||||||
|
Search the web and X for the LATEST news, analyst opinions, and market developments \
|
||||||
|
about the stock ticker {ticker} from the past 24-48 hours.
|
||||||
|
|
||||||
|
Based on your search findings, analyze the CURRENT market sentiment.
|
||||||
|
|
||||||
|
Respond ONLY with a JSON object in this exact format (no markdown, no extra text):
|
||||||
|
{{"classification": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation citing recent news>"}}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- classification must be exactly one of: bullish, bearish, neutral
|
||||||
|
- confidence must be an integer from 0 to 100
|
||||||
|
- reasoning should cite specific recent news or events you found
|
||||||
|
"""
|
||||||
|
|
||||||
|
VALID_CLASSIFICATIONS = {"bullish", "bearish", "neutral"}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_json_text(raw: str) -> str:
|
||||||
|
clean = raw.strip()
|
||||||
|
if clean.startswith("```"):
|
||||||
|
clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
|
||||||
|
if clean.endswith("```"):
|
||||||
|
clean = clean[:-3]
|
||||||
|
return clean.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatibleSentimentProvider:
|
||||||
|
"""Sentiment via the OpenAI Chat Completions API at a configurable base_url."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
source: str = "openai_compatible",
|
||||||
|
live_search: bool = False,
|
||||||
|
extra_body: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not api_key:
|
||||||
|
raise ProviderError("API key is required")
|
||||||
|
if not base_url:
|
||||||
|
raise ProviderError("base_url is required for an OpenAI-compatible provider")
|
||||||
|
if not model:
|
||||||
|
raise ProviderError("model is required")
|
||||||
|
|
||||||
|
http_kwargs: dict = {}
|
||||||
|
if _CA_BUNDLE and Path(_CA_BUNDLE).exists():
|
||||||
|
http_kwargs["verify"] = _CA_BUNDLE
|
||||||
|
http_client = httpx.AsyncClient(**http_kwargs)
|
||||||
|
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, http_client=http_client)
|
||||||
|
self._model = model
|
||||||
|
self._source = source
|
||||||
|
self._live_search = live_search
|
||||||
|
self._extra_body = extra_body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_citations(response: object) -> list[dict[str, str]]:
|
||||||
|
"""Best-effort extraction of xAI Live Search citations (list of URLs)."""
|
||||||
|
raw = getattr(response, "citations", None)
|
||||||
|
if not raw:
|
||||||
|
extra = getattr(response, "model_extra", None) or {}
|
||||||
|
raw = extra.get("citations") if isinstance(extra, dict) else None
|
||||||
|
citations: list[dict[str, str]] = []
|
||||||
|
for item in raw or []:
|
||||||
|
if isinstance(item, str):
|
||||||
|
citations.append({"url": item, "title": ""})
|
||||||
|
elif isinstance(item, dict) and item.get("url"):
|
||||||
|
citations.append({"url": str(item["url"]), "title": str(item.get("title", ""))})
|
||||||
|
return citations
|
||||||
|
|
||||||
|
async def fetch_sentiment(self, ticker: str) -> SentimentData:
|
||||||
|
prompt = _SENTIMENT_PROMPT_SEARCH if self._live_search else _SENTIMENT_PROMPT
|
||||||
|
kwargs: dict = {}
|
||||||
|
if self._extra_body:
|
||||||
|
kwargs["extra_body"] = self._extra_body
|
||||||
|
try:
|
||||||
|
response = await self._client.chat.completions.create(
|
||||||
|
model=self._model,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "You are a financial sentiment analyst. Always respond with valid JSON only, no markdown fences.",
|
||||||
|
},
|
||||||
|
{"role": "user", "content": prompt.format(ticker=ticker)},
|
||||||
|
],
|
||||||
|
temperature=0.3,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
raw_text = (response.choices[0].message.content or "").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ProviderError(f"Empty response from {self._source} for {ticker}")
|
||||||
|
|
||||||
|
parsed = json.loads(_clean_json_text(raw_text))
|
||||||
|
|
||||||
|
classification = str(parsed.get("classification", "")).lower()
|
||||||
|
if classification not in VALID_CLASSIFICATIONS:
|
||||||
|
raise ProviderError(
|
||||||
|
f"Invalid classification '{classification}' from {self._source} for {ticker}"
|
||||||
|
)
|
||||||
|
|
||||||
|
confidence = max(0, min(100, int(parsed.get("confidence", 50))))
|
||||||
|
reasoning = str(parsed.get("reasoning", ""))
|
||||||
|
if reasoning:
|
||||||
|
logger.info(
|
||||||
|
"%s sentiment for %s: %s (confidence=%d) — %s",
|
||||||
|
self._source, ticker, classification, confidence, reasoning,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SentimentData(
|
||||||
|
ticker=ticker,
|
||||||
|
classification=classification,
|
||||||
|
confidence=confidence,
|
||||||
|
source=self._source,
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
reasoning=reasoning,
|
||||||
|
citations=self._extract_citations(response) if self._live_search else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.error("Failed to parse %s JSON for %s: %s", self._source, ticker, exc)
|
||||||
|
raise ProviderError(f"Invalid JSON from {self._source} for {ticker}") from exc
|
||||||
|
except ProviderError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "429" in msg or "rate" in msg or "quota" in msg or "insufficient" in msg:
|
||||||
|
raise RateLimitError(f"{self._source} rate limit hit for {ticker}") from exc
|
||||||
|
logger.error("%s provider error for %s: %s", self._source, ticker, exc)
|
||||||
|
raise ProviderError(f"{self._source} provider error for {ticker}: {exc}") from exc
|
||||||
@@ -194,6 +194,7 @@ async def update_sentiment_settings(
|
|||||||
provider=body.provider,
|
provider=body.provider,
|
||||||
model=body.model,
|
model=body.model,
|
||||||
api_key=body.api_key,
|
api_key=body.api_key,
|
||||||
|
base_url=body.base_url,
|
||||||
)
|
)
|
||||||
return APIEnvelope(status="success", data=config)
|
return APIEnvelope(status="success", data=config)
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,10 @@ class ActivationConfigUpdate(BaseModel):
|
|||||||
class SentimentConfigUpdate(BaseModel):
|
class SentimentConfigUpdate(BaseModel):
|
||||||
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
|
"""Runtime sentiment LLM config. api_key is write-only; omit/empty to keep
|
||||||
the stored key."""
|
the stored key."""
|
||||||
provider: Literal["openai", "gemini"] | None = None
|
provider: Literal["openai", "gemini", "deepseek", "xai", "openai_compatible"] | None = None
|
||||||
model: str | None = Field(default=None, max_length=100)
|
model: str | None = Field(default=None, max_length=100)
|
||||||
api_key: str | None = Field(default=None, max_length=400)
|
api_key: str | None = Field(default=None, max_length=400)
|
||||||
|
base_url: str | None = Field(default=None, max_length=300)
|
||||||
|
|
||||||
|
|
||||||
class SentimentTestRequest(BaseModel):
|
class SentimentTestRequest(BaseModel):
|
||||||
|
|||||||
@@ -22,23 +22,43 @@ from app.services.admin_service import update_setting
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
VALID_PROVIDERS = {"openai", "gemini"}
|
VALID_PROVIDERS = {"openai", "gemini", "deepseek", "xai", "openai_compatible"}
|
||||||
|
|
||||||
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||||
"openai": "gpt-4o-mini",
|
"openai": "gpt-4o-mini",
|
||||||
"gemini": "gemini-2.0-flash",
|
"gemini": "gemini-2.0-flash",
|
||||||
|
"deepseek": "deepseek-chat",
|
||||||
|
"xai": "grok-3",
|
||||||
|
"openai_compatible": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fixed base URLs for named OpenAI-compatible providers.
|
||||||
|
PROVIDER_BASE_URLS: dict[str, str] = {
|
||||||
|
"deepseek": "https://api.deepseek.com",
|
||||||
|
"xai": "https://api.x.ai/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Providers grounded in live web search. The rest score from model knowledge.
|
||||||
|
# xAI grounds via Live Search (search_parameters); OpenAI/Gemini via their tools.
|
||||||
|
WEB_SEARCH_PROVIDERS = {"openai", "gemini", "xai"}
|
||||||
|
|
||||||
|
# xAI Live Search: auto mode lets Grok search web + X when the query needs it.
|
||||||
|
_XAI_SEARCH_PARAMETERS = {"mode": "auto", "return_citations": True}
|
||||||
|
|
||||||
|
# Providers needing a user-supplied base URL (generic compatible endpoints).
|
||||||
|
CUSTOM_BASE_URL_PROVIDERS = {"openai_compatible"}
|
||||||
|
|
||||||
# SystemSetting keys
|
# SystemSetting keys
|
||||||
KEY_PROVIDER = "sentiment_provider"
|
KEY_PROVIDER = "sentiment_provider"
|
||||||
KEY_MODEL = "sentiment_model"
|
KEY_MODEL = "sentiment_model"
|
||||||
KEY_API_KEY = "sentiment_api_key"
|
KEY_API_KEY = "sentiment_api_key"
|
||||||
|
KEY_BASE_URL = "sentiment_base_url"
|
||||||
|
|
||||||
|
|
||||||
async def _get_settings_map(db: AsyncSession) -> dict[str, str]:
|
async def _get_settings_map(db: AsyncSession) -> dict[str, str]:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemSetting).where(
|
select(SystemSetting).where(
|
||||||
SystemSetting.key.in_([KEY_PROVIDER, KEY_MODEL, KEY_API_KEY])
|
SystemSetting.key.in_([KEY_PROVIDER, KEY_MODEL, KEY_API_KEY, KEY_BASE_URL])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return {s.key: s.value for s in result.scalars().all()}
|
return {s.key: s.value for s in result.scalars().all()}
|
||||||
@@ -49,6 +69,10 @@ def _env_key_for(provider: str) -> str:
|
|||||||
return settings.openai_api_key or ""
|
return settings.openai_api_key or ""
|
||||||
if provider == "gemini":
|
if provider == "gemini":
|
||||||
return settings.gemini_api_key or ""
|
return settings.gemini_api_key or ""
|
||||||
|
if provider == "deepseek":
|
||||||
|
return getattr(settings, "deepseek_api_key", "") or ""
|
||||||
|
if provider == "xai":
|
||||||
|
return getattr(settings, "xai_api_key", "") or ""
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -60,36 +84,54 @@ def _env_model_for(provider: str) -> str:
|
|||||||
return PROVIDER_DEFAULT_MODELS.get(provider, "")
|
return PROVIDER_DEFAULT_MODELS.get(provider, "")
|
||||||
|
|
||||||
|
|
||||||
async def _resolve(db: AsyncSession) -> tuple[str, str, str, str]:
|
def _base_url_for(provider: str, stored_base_url: str) -> str:
|
||||||
"""Resolve (provider, model, api_key, api_key_source) from DB > env > default."""
|
if provider in PROVIDER_BASE_URLS:
|
||||||
|
return PROVIDER_BASE_URLS[provider]
|
||||||
|
return stored_base_url
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve(db: AsyncSession) -> dict:
|
||||||
|
"""Resolve effective config from DB > env > default."""
|
||||||
stored = await _get_settings_map(db)
|
stored = await _get_settings_map(db)
|
||||||
|
|
||||||
provider = (stored.get(KEY_PROVIDER) or "").strip().lower()
|
provider = (stored.get(KEY_PROVIDER) or "").strip().lower()
|
||||||
if provider not in VALID_PROVIDERS:
|
if provider not in VALID_PROVIDERS:
|
||||||
# Default to whichever env key is present, else openai
|
|
||||||
provider = "openai" if settings.openai_api_key or not settings.gemini_api_key else "gemini"
|
provider = "openai" if settings.openai_api_key or not settings.gemini_api_key else "gemini"
|
||||||
|
|
||||||
model = (stored.get(KEY_MODEL) or "").strip() or _env_model_for(provider)
|
model = (stored.get(KEY_MODEL) or "").strip() or _env_model_for(provider)
|
||||||
|
base_url = _base_url_for(provider, (stored.get(KEY_BASE_URL) or "").strip())
|
||||||
|
|
||||||
db_key = (stored.get(KEY_API_KEY) or "").strip()
|
db_key = (stored.get(KEY_API_KEY) or "").strip()
|
||||||
if db_key:
|
if db_key:
|
||||||
return provider, model, db_key, "database"
|
api_key, source = db_key, "database"
|
||||||
env_key = _env_key_for(provider)
|
elif _env_key_for(provider):
|
||||||
if env_key:
|
api_key, source = _env_key_for(provider), "environment"
|
||||||
return provider, model, env_key, "environment"
|
else:
|
||||||
return provider, model, "", "none"
|
api_key, source = "", "none"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": provider,
|
||||||
|
"model": model,
|
||||||
|
"base_url": base_url,
|
||||||
|
"api_key": api_key,
|
||||||
|
"api_key_source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_sentiment_config(db: AsyncSession) -> dict:
|
async def get_sentiment_config(db: AsyncSession) -> dict:
|
||||||
"""Public config — never includes the raw API key."""
|
"""Public config — never includes the raw API key."""
|
||||||
provider, model, api_key, source = await _resolve(db)
|
r = await _resolve(db)
|
||||||
return {
|
return {
|
||||||
"provider": provider,
|
"provider": r["provider"],
|
||||||
"model": model,
|
"model": r["model"],
|
||||||
"api_key_configured": bool(api_key),
|
"base_url": r["base_url"],
|
||||||
"api_key_source": source,
|
"api_key_configured": bool(r["api_key"]),
|
||||||
|
"api_key_source": r["api_key_source"],
|
||||||
|
"web_search": r["provider"] in WEB_SEARCH_PROVIDERS,
|
||||||
"valid_providers": sorted(VALID_PROVIDERS),
|
"valid_providers": sorted(VALID_PROVIDERS),
|
||||||
"default_models": PROVIDER_DEFAULT_MODELS,
|
"default_models": PROVIDER_DEFAULT_MODELS,
|
||||||
|
"web_search_providers": sorted(WEB_SEARCH_PROVIDERS),
|
||||||
|
"custom_base_url_providers": sorted(CUSTOM_BASE_URL_PROVIDERS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -98,9 +140,9 @@ async def update_sentiment_config(
|
|||||||
provider: str | None = None,
|
provider: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Persist provider/model/key. An empty/omitted api_key leaves the stored
|
"""Persist config. An empty/omitted api_key leaves the stored key untouched."""
|
||||||
key untouched (so saving other fields does not wipe credentials)."""
|
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
provider = provider.strip().lower()
|
provider = provider.strip().lower()
|
||||||
if provider not in VALID_PROVIDERS:
|
if provider not in VALID_PROVIDERS:
|
||||||
@@ -114,6 +156,9 @@ async def update_sentiment_config(
|
|||||||
if model:
|
if model:
|
||||||
await update_setting(db, KEY_MODEL, model)
|
await update_setting(db, KEY_MODEL, model)
|
||||||
|
|
||||||
|
if base_url is not None:
|
||||||
|
await update_setting(db, KEY_BASE_URL, base_url.strip())
|
||||||
|
|
||||||
if api_key: # only overwrite when a non-empty key is supplied
|
if api_key: # only overwrite when a non-empty key is supplied
|
||||||
await update_setting(db, KEY_API_KEY, api_key.strip())
|
await update_setting(db, KEY_API_KEY, api_key.strip())
|
||||||
|
|
||||||
@@ -123,9 +168,10 @@ async def update_sentiment_config(
|
|||||||
async def build_sentiment_provider(db: AsyncSession):
|
async def build_sentiment_provider(db: AsyncSession):
|
||||||
"""Construct the active sentiment provider from current config.
|
"""Construct the active sentiment provider from current config.
|
||||||
|
|
||||||
Raises ProviderError if no API key is available for the chosen provider.
|
Raises ProviderError if credentials/base_url are missing.
|
||||||
"""
|
"""
|
||||||
provider, model, api_key, _source = await _resolve(db)
|
r = await _resolve(db)
|
||||||
|
provider, model, base_url, api_key = r["provider"], r["model"], r["base_url"], r["api_key"]
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise ProviderError(f"No API key configured for sentiment provider '{provider}'")
|
raise ProviderError(f"No API key configured for sentiment provider '{provider}'")
|
||||||
|
|
||||||
@@ -135,20 +181,31 @@ async def build_sentiment_provider(db: AsyncSession):
|
|||||||
if provider == "gemini":
|
if provider == "gemini":
|
||||||
from app.providers.gemini_sentiment import GeminiSentimentProvider
|
from app.providers.gemini_sentiment import GeminiSentimentProvider
|
||||||
return GeminiSentimentProvider(api_key, model)
|
return GeminiSentimentProvider(api_key, model)
|
||||||
|
if provider in {"deepseek", "xai", "openai_compatible"}:
|
||||||
|
if not base_url:
|
||||||
|
raise ProviderError(f"No base_url configured for sentiment provider '{provider}'")
|
||||||
|
from app.providers.openai_compatible_sentiment import OpenAICompatibleSentimentProvider
|
||||||
|
if provider == "xai":
|
||||||
|
return OpenAICompatibleSentimentProvider(
|
||||||
|
api_key, model, base_url, source="xai",
|
||||||
|
live_search=True,
|
||||||
|
extra_body={"search_parameters": _XAI_SEARCH_PARAMETERS},
|
||||||
|
)
|
||||||
|
return OpenAICompatibleSentimentProvider(api_key, model, base_url, source=provider)
|
||||||
|
|
||||||
raise ProviderError(f"Unsupported sentiment provider '{provider}'")
|
raise ProviderError(f"Unsupported sentiment provider '{provider}'")
|
||||||
|
|
||||||
|
|
||||||
async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dict:
|
async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dict:
|
||||||
"""Build the active provider and fetch one ticker as a live credentials check."""
|
"""Build the active provider and fetch one ticker as a live credentials check."""
|
||||||
provider, model, _key, _source = await _resolve(db)
|
r = await _resolve(db)
|
||||||
try:
|
try:
|
||||||
prov = await build_sentiment_provider(db)
|
prov = await build_sentiment_provider(db)
|
||||||
data = await prov.fetch_sentiment(ticker.strip().upper())
|
data = await prov.fetch_sentiment(ticker.strip().upper())
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"provider": provider,
|
"provider": r["provider"],
|
||||||
"model": model,
|
"model": r["model"],
|
||||||
"ticker": ticker.strip().upper(),
|
"ticker": ticker.strip().upper(),
|
||||||
"classification": data.classification,
|
"classification": data.classification,
|
||||||
"confidence": data.confidence,
|
"confidence": data.confidence,
|
||||||
@@ -158,7 +215,7 @@ async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dic
|
|||||||
logger.warning("Sentiment provider test failed: %s", exc)
|
logger.warning("Sentiment provider test failed: %s", exc)
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"provider": provider,
|
"provider": r["provider"],
|
||||||
"model": model,
|
"model": r["model"],
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useUpdateSentimentSettings,
|
useUpdateSentimentSettings,
|
||||||
useTestSentimentProvider,
|
useTestSentimentProvider,
|
||||||
} from '../../hooks/useAdmin';
|
} from '../../hooks/useAdmin';
|
||||||
import { Select } from '../ui/Field';
|
import { Dropdown, type DropdownOption } from '../ui/Dropdown';
|
||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
import type { SentimentTestResult } from '../../lib/types';
|
import type { SentimentTestResult } from '../../lib/types';
|
||||||
|
|
||||||
@@ -14,6 +14,18 @@ const SOURCE_LABEL: Record<string, string> = {
|
|||||||
none: 'not configured',
|
none: 'not configured',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
openai: 'OpenAI — web search',
|
||||||
|
gemini: 'Google Gemini — web search',
|
||||||
|
deepseek: 'DeepSeek — cheap, no web search',
|
||||||
|
xai: 'xAI Grok — Live Search',
|
||||||
|
openai_compatible: 'OpenAI-compatible — custom URL',
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerLabel(p: string): string {
|
||||||
|
return PROVIDER_LABELS[p] ?? p;
|
||||||
|
}
|
||||||
|
|
||||||
export function SentimentProviderSettings() {
|
export function SentimentProviderSettings() {
|
||||||
const { data, isLoading, isError, error } = useSentimentSettings();
|
const { data, isLoading, isError, error } = useSentimentSettings();
|
||||||
const update = useUpdateSentimentSettings();
|
const update = useUpdateSentimentSettings();
|
||||||
@@ -22,12 +34,14 @@ export function SentimentProviderSettings() {
|
|||||||
const [provider, setProvider] = useState('openai');
|
const [provider, setProvider] = useState('openai');
|
||||||
const [model, setModel] = useState('');
|
const [model, setModel] = useState('');
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
const [testResult, setTestResult] = useState<SentimentTestResult | null>(null);
|
const [testResult, setTestResult] = useState<SentimentTestResult | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setProvider(data.provider);
|
setProvider(data.provider);
|
||||||
setModel(data.model);
|
setModel(data.model);
|
||||||
|
setBaseUrl(data.base_url ?? '');
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -35,10 +49,17 @@ export function SentimentProviderSettings() {
|
|||||||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
|
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
const grounded = data.web_search_providers ?? ['openai', 'gemini'];
|
||||||
|
const needsBaseUrl = (data.custom_base_url_providers ?? ['openai_compatible']).includes(provider);
|
||||||
|
const isGrounded = grounded.includes(provider);
|
||||||
|
|
||||||
|
const providerOptions: DropdownOption[] = data.valid_providers.map((p) => ({
|
||||||
|
value: p,
|
||||||
|
label: providerLabel(p),
|
||||||
|
}));
|
||||||
|
|
||||||
const onProviderChange = (next: string) => {
|
const onProviderChange = (next: string) => {
|
||||||
setProvider(next);
|
setProvider(next);
|
||||||
// Auto-fill the model with the new provider's default unless the user has a
|
|
||||||
// custom value that isn't the previous provider's default.
|
|
||||||
const defaults = data.default_models;
|
const defaults = data.default_models;
|
||||||
if (!model || Object.values(defaults).includes(model)) {
|
if (!model || Object.values(defaults).includes(model)) {
|
||||||
setModel(defaults[next] ?? '');
|
setModel(defaults[next] ?? '');
|
||||||
@@ -50,6 +71,7 @@ export function SentimentProviderSettings() {
|
|||||||
update.mutate({
|
update.mutate({
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
...(needsBaseUrl ? { base_url: baseUrl } : {}),
|
||||||
...(apiKey ? { api_key: apiKey } : {}),
|
...(apiKey ? { api_key: apiKey } : {}),
|
||||||
});
|
});
|
||||||
setApiKey('');
|
setApiKey('');
|
||||||
@@ -74,11 +96,7 @@ export function SentimentProviderSettings() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">Provider</span>
|
<span className="text-xs text-gray-400">Provider</span>
|
||||||
<Select value={provider} onChange={(e) => onProviderChange(e.target.value)} className="w-full !py-2">
|
<Dropdown value={provider} onChange={onProviderChange} options={providerOptions} ariaLabel="Sentiment provider" />
|
||||||
{data.valid_providers.map((p) => (
|
|
||||||
<option key={p} value={p}>{p}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
@@ -87,12 +105,33 @@ export function SentimentProviderSettings() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
placeholder={data.default_models[provider] ?? ''}
|
placeholder={data.default_models[provider] || 'model id'}
|
||||||
className="w-full input-glass px-3 py-2 text-sm"
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{needsBaseUrl && (
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs text-gray-400">Base URL</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="https://openrouter.ai/api/v1"
|
||||||
|
className="w-full input-glass px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] text-gray-600">OpenAI-compatible Chat Completions endpoint (OpenRouter, Together, Groq, local Ollama…).</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isGrounded && (
|
||||||
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-300">
|
||||||
|
⚠ No live web search. This provider scores sentiment from the model's training knowledge,
|
||||||
|
not current news — cheaper, but not real-time. OpenAI and Gemini are grounded in live search.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
<span className="text-xs text-gray-400">API Key</span>
|
<span className="text-xs text-gray-400">API Key</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useUpdateTickerUniverseSetting,
|
useUpdateTickerUniverseSetting,
|
||||||
} from '../../hooks/useAdmin';
|
} from '../../hooks/useAdmin';
|
||||||
import type { TickerUniverse } from '../../lib/types';
|
import type { TickerUniverse } from '../../lib/types';
|
||||||
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
|
|
||||||
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
|
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
|
||||||
{ value: 'sp500', label: 'S&P 500' },
|
{ value: 'sp500', label: 'S&P 500' },
|
||||||
@@ -50,18 +51,11 @@ export function TickerUniverseBootstrap() {
|
|||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<label className="block space-y-1 md:col-span-2">
|
<label className="block space-y-1 md:col-span-2">
|
||||||
<span className="text-xs text-gray-400">Default Universe</span>
|
<span className="text-xs text-gray-400">Default Universe</span>
|
||||||
<select
|
<Dropdown
|
||||||
value={universe}
|
value={universe}
|
||||||
onChange={(e) => setUniverse(e.target.value as TickerUniverse)}
|
onChange={(v) => setUniverse(v as TickerUniverse)}
|
||||||
className="w-full input-glass px-3 py-2 text-sm"
|
options={UNIVERSE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
|
/>
|
||||||
>
|
|
||||||
{UNIVERSE_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-end gap-2 pb-2">
|
<label className="flex items-end gap-2 pb-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
|
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
|
||||||
import { SkeletonTable } from '../ui/Skeleton';
|
import { SkeletonTable } from '../ui/Skeleton';
|
||||||
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
import type { AdminUser } from '../../lib/types';
|
import type { AdminUser } from '../../lib/types';
|
||||||
|
|
||||||
export function UserTable() {
|
export function UserTable() {
|
||||||
@@ -53,10 +54,15 @@ export function UserTable() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs text-gray-400">Role</label>
|
<label className="text-xs text-gray-400">Role</label>
|
||||||
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
|
<Dropdown
|
||||||
<option value="user">User</option>
|
value={newRole}
|
||||||
<option value="admin">Admin</option>
|
onChange={setNewRole}
|
||||||
</select>
|
className="w-32"
|
||||||
|
options={[
|
||||||
|
{ value: 'user', label: 'User' },
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
|
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
|
||||||
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
|
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useToast } from '../ui/Toast';
|
|||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
import { Field, Input, Select } from '../ui/Field';
|
import { Field, Input } from '../ui/Field';
|
||||||
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
import { triggerJob } from '../../api/admin';
|
import { triggerJob } from '../../api/admin';
|
||||||
import type { TradeSetup } from '../../lib/types';
|
import type { TradeSetup } from '../../lib/types';
|
||||||
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
||||||
@@ -178,15 +179,17 @@ export function SetupsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Direction" htmlFor="direction">
|
<Field label="Direction" htmlFor="direction">
|
||||||
<Select
|
<Dropdown
|
||||||
id="direction"
|
id="direction"
|
||||||
value={directionFilter}
|
value={directionFilter}
|
||||||
onChange={(e) => setDirectionFilter(e.target.value as DirectionFilter)}
|
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
|
||||||
>
|
className="w-32"
|
||||||
<option value="both">Both</option>
|
options={[
|
||||||
<option value="long">Long</option>
|
{ value: 'both', label: 'Both' },
|
||||||
<option value="short">Short</option>
|
{ value: 'long', label: 'Long' },
|
||||||
</Select>
|
{ value: 'short', label: 'Short' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Min Confidence" htmlFor="min-confidence">
|
<Field label="Min Confidence" htmlFor="min-confidence">
|
||||||
<Input
|
<Input
|
||||||
@@ -201,18 +204,20 @@ export function SetupsPanel() {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Recommended Action" htmlFor="action">
|
<Field label="Recommended Action" htmlFor="action">
|
||||||
<Select
|
<Dropdown
|
||||||
id="action"
|
id="action"
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
|
onChange={(v) => setActionFilter(v as ActionFilter)}
|
||||||
>
|
className="w-56"
|
||||||
<option value="all">All</option>
|
options={[
|
||||||
<option value="LONG_HIGH">{RECOMMENDATION_ACTION_LABELS.LONG_HIGH}</option>
|
{ value: 'all', label: 'All' },
|
||||||
<option value="LONG_MODERATE">{RECOMMENDATION_ACTION_LABELS.LONG_MODERATE}</option>
|
{ value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH },
|
||||||
<option value="SHORT_HIGH">{RECOMMENDATION_ACTION_LABELS.SHORT_HIGH}</option>
|
{ value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE },
|
||||||
<option value="SHORT_MODERATE">{RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE}</option>
|
{ value: 'SHORT_HIGH', label: RECOMMENDATION_ACTION_LABELS.SHORT_HIGH },
|
||||||
<option value="NEUTRAL">{RECOMMENDATION_ACTION_LABELS.NEUTRAL}</option>
|
{ value: 'SHORT_MODERATE', label: RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE },
|
||||||
</Select>
|
{ value: 'NEUTRAL', label: RECOMMENDATION_ACTION_LABELS.NEUTRAL },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
|
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getIndicator, getEMACross } from '../../api/indicators';
|
import { getIndicator, getEMACross } from '../../api/indicators';
|
||||||
import { Select } from '../ui/Field';
|
import { Dropdown } from '../ui/Dropdown';
|
||||||
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
|
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
|
||||||
|
|
||||||
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
|
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
|
||||||
@@ -86,16 +86,12 @@ export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
|
|||||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
|
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Select
|
<Dropdown
|
||||||
value={selectedType}
|
value={selectedType}
|
||||||
onChange={(e) => setSelectedType(e.target.value)}
|
onChange={setSelectedType}
|
||||||
className="w-full !py-2.5"
|
placeholder="Select indicator…"
|
||||||
>
|
options={INDICATOR_TYPES.map((type) => ({ value: type, label: type }))}
|
||||||
<option value="">Select indicator…</option>
|
/>
|
||||||
{INDICATOR_TYPES.map((type) => (
|
|
||||||
<option key={type} value={type}>{type}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedType && indicatorQuery.isLoading && (
|
{selectedType && indicatorQuery.isLoading && (
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface DropdownOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: DropdownOption[];
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully dark, custom dropdown — replaces native <select>, whose popup list
|
||||||
|
* can't be reliably styled (white background on Windows). Button + absolutely
|
||||||
|
* positioned menu, click-outside and Escape to close.
|
||||||
|
*/
|
||||||
|
export function Dropdown({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
id,
|
||||||
|
className = '',
|
||||||
|
ariaLabel,
|
||||||
|
placeholder = 'Select…',
|
||||||
|
}: DropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={id}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
|
||||||
|
>
|
||||||
|
<span className={selected ? 'text-gray-200' : 'text-gray-500'}>
|
||||||
|
{selected ? selected.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 shrink-0 text-gray-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M6 8l4 4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-white/[0.1] bg-[#151911] p-1 shadow-2xl shadow-black/50"
|
||||||
|
>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const isSelected = opt.value === value;
|
||||||
|
return (
|
||||||
|
<li key={opt.value} role="option" aria-selected={isSelected}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors duration-100 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-400/15 text-blue-200'
|
||||||
|
: 'text-gray-300 hover:bg-white/[0.06] hover:text-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
{isSelected && (
|
||||||
|
<svg className="h-3.5 w-3.5 text-blue-300" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.3 3.3 6.8-6.8a1 1 0 011.4 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
|
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
interface FieldProps {
|
interface FieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -21,11 +21,3 @@ export function Field({ label, htmlFor, children }: FieldProps) {
|
|||||||
export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
return <input className={`input-glass px-3 py-1.5 text-sm ${className}`} {...rest} />;
|
return <input className={`input-glass px-3 py-1.5 text-sm ${className}`} {...rest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
||||||
return (
|
|
||||||
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#151911] ${className}`} {...rest}>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -165,10 +165,14 @@ export interface ActivationConfig {
|
|||||||
export interface SentimentProviderConfig {
|
export interface SentimentProviderConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
base_url: string;
|
||||||
api_key_configured: boolean;
|
api_key_configured: boolean;
|
||||||
api_key_source: 'database' | 'environment' | 'none';
|
api_key_source: 'database' | 'environment' | 'none';
|
||||||
|
web_search: boolean;
|
||||||
valid_providers: string[];
|
valid_providers: string[];
|
||||||
default_models: Record<string, string>;
|
default_models: Record<string, string>;
|
||||||
|
web_search_providers: string[];
|
||||||
|
custom_base_url_providers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentimentTestResult {
|
export interface SentimentTestResult {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { RankingsTable } from '../components/rankings/RankingsTable';
|
|||||||
import { WeightsForm } from '../components/rankings/WeightsForm';
|
import { WeightsForm } from '../components/rankings/WeightsForm';
|
||||||
import { Callout } from '../components/ui/Callout';
|
import { Callout } from '../components/ui/Callout';
|
||||||
import { Disclosure } from '../components/ui/Disclosure';
|
import { Disclosure } from '../components/ui/Disclosure';
|
||||||
import { Select } from '../components/ui/Field';
|
import { Dropdown } from '../components/ui/Dropdown';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||||
import { Tabs } from '../components/ui/Tabs';
|
import { Tabs } from '../components/ui/Tabs';
|
||||||
@@ -72,16 +72,17 @@ function WatchlistPanel() {
|
|||||||
<AddTickerForm />
|
<AddTickerForm />
|
||||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
<span>Sort by</span>
|
<span>Sort by</span>
|
||||||
<Select
|
<Dropdown
|
||||||
value={sortMode}
|
value={sortMode}
|
||||||
onChange={(event) => setSortMode(event.target.value as SortMode)}
|
onChange={(v) => setSortMode(v as SortMode)}
|
||||||
className="!py-1 !text-xs"
|
className="w-44"
|
||||||
>
|
options={[
|
||||||
<option value="score_desc">Score (high → low)</option>
|
{ value: 'score_desc', label: 'Score (high → low)' },
|
||||||
<option value="score_asc">Score (low → high)</option>
|
{ value: 'score_asc', label: 'Score (low → high)' },
|
||||||
<option value="name_asc">Name (A → Z)</option>
|
{ value: 'name_asc', label: 'Name (A → Z)' },
|
||||||
<option value="name_desc">Name (Z → A)</option>
|
{ value: 'name_desc', label: 'Name (Z → A)' },
|
||||||
</Select>
|
]}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||||
@@ -33,7 +33,8 @@ class TestGetConfig:
|
|||||||
assert config["model"] == "gpt-4o-mini"
|
assert config["model"] == "gpt-4o-mini"
|
||||||
assert config["api_key_configured"] is False
|
assert config["api_key_configured"] is False
|
||||||
assert config["api_key_source"] == "none"
|
assert config["api_key_source"] == "none"
|
||||||
assert set(config["valid_providers"]) == {"openai", "gemini"}
|
assert config["web_search"] is True
|
||||||
|
assert set(config["valid_providers"]) == {"openai", "gemini", "deepseek", "xai", "openai_compatible"}
|
||||||
|
|
||||||
async def test_never_returns_raw_key(self, session: AsyncSession):
|
async def test_never_returns_raw_key(self, session: AsyncSession):
|
||||||
await sps.update_sentiment_config(session, api_key="sk-secret-123")
|
await sps.update_sentiment_config(session, api_key="sk-secret-123")
|
||||||
@@ -92,3 +93,38 @@ class TestBuildProvider:
|
|||||||
await sps.update_sentiment_config(session, provider="openai")
|
await sps.update_sentiment_config(session, provider="openai")
|
||||||
provider = await sps.build_sentiment_provider(session)
|
provider = await sps.build_sentiment_provider(session)
|
||||||
assert type(provider).__name__ == "OpenAISentimentProvider"
|
assert type(provider).__name__ == "OpenAISentimentProvider"
|
||||||
|
|
||||||
|
async def test_builds_deepseek_with_fixed_base_url(self, session: AsyncSession):
|
||||||
|
await sps.update_sentiment_config(session, provider="deepseek", api_key="ds-x")
|
||||||
|
provider = await sps.build_sentiment_provider(session)
|
||||||
|
assert type(provider).__name__ == "OpenAICompatibleSentimentProvider"
|
||||||
|
config = await sps.get_sentiment_config(session)
|
||||||
|
assert config["base_url"] == "https://api.deepseek.com"
|
||||||
|
assert config["web_search"] is False
|
||||||
|
|
||||||
|
async def test_builds_xai_with_live_search(self, session: AsyncSession):
|
||||||
|
await sps.update_sentiment_config(session, provider="xai", api_key="xai-x")
|
||||||
|
provider = await sps.build_sentiment_provider(session)
|
||||||
|
assert type(provider).__name__ == "OpenAICompatibleSentimentProvider"
|
||||||
|
# xAI is wired with Live Search enabled
|
||||||
|
assert provider._live_search is True
|
||||||
|
assert provider._extra_body == {"search_parameters": {"mode": "auto", "return_citations": True}}
|
||||||
|
config = await sps.get_sentiment_config(session)
|
||||||
|
assert config["base_url"] == "https://api.x.ai/v1"
|
||||||
|
assert config["web_search"] is True
|
||||||
|
|
||||||
|
async def test_openai_compatible_requires_base_url(self, session: AsyncSession):
|
||||||
|
await sps.update_sentiment_config(session, provider="openai_compatible", api_key="x")
|
||||||
|
with pytest.raises(ProviderError):
|
||||||
|
await sps.build_sentiment_provider(session)
|
||||||
|
|
||||||
|
async def test_openai_compatible_with_base_url(self, session: AsyncSession):
|
||||||
|
await sps.update_sentiment_config(
|
||||||
|
session,
|
||||||
|
provider="openai_compatible",
|
||||||
|
api_key="x",
|
||||||
|
model="llama-3.1-70b",
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
)
|
||||||
|
provider = await sps.build_sentiment_provider(session)
|
||||||
|
assert type(provider).__name__ == "OpenAICompatibleSentimentProvider"
|
||||||
|
|||||||
Reference in New Issue
Block a user