Add multi-factor conviction gate to activation
Make "qualified" mean an edge candidate, not just R:R + confidence. The gate now also requires (all admin-configurable, defaults on): - high conviction: recommended_action LONG_HIGH / SHORT_HIGH only - clean read: risk_level Low (no contradicting signals) - probable primary target: best target probability >= min (default 60) - Shared predicate: app/services/qualification.py + frontend/src/lib/qualification.ts (mirrored) - Activation config extended (min_target_probability, require_high_conviction, exclude_conflicts) with bool-aware get/update + validation - /trades/performance switched to ?qualified_only=true, applying the full gate server-side; confidence breakdown stays unfiltered - Dashboard "Qualified", Signals "Qualified only" toggle, and Track Record all use the one gate; Admin gains the new controls Sentiment provider runtime config (prior change) included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""Runtime-configurable sentiment provider.
|
||||
|
||||
Lets an admin switch the sentiment LLM (provider, model, API key) at runtime
|
||||
via SystemSetting, without a redeploy. Precedence for each value:
|
||||
DB setting > environment variable > hardcoded default.
|
||||
|
||||
The API key is write-only: it is persisted but never returned through any
|
||||
read path. Reads report only whether a key is configured and its source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.exceptions import ProviderError, ValidationError
|
||||
from app.models.settings import SystemSetting
|
||||
from app.services.admin_service import update_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_PROVIDERS = {"openai", "gemini"}
|
||||
|
||||
PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||
"openai": "gpt-4o-mini",
|
||||
"gemini": "gemini-2.0-flash",
|
||||
}
|
||||
|
||||
# SystemSetting keys
|
||||
KEY_PROVIDER = "sentiment_provider"
|
||||
KEY_MODEL = "sentiment_model"
|
||||
KEY_API_KEY = "sentiment_api_key"
|
||||
|
||||
|
||||
async def _get_settings_map(db: AsyncSession) -> dict[str, str]:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(
|
||||
SystemSetting.key.in_([KEY_PROVIDER, KEY_MODEL, KEY_API_KEY])
|
||||
)
|
||||
)
|
||||
return {s.key: s.value for s in result.scalars().all()}
|
||||
|
||||
|
||||
def _env_key_for(provider: str) -> str:
|
||||
if provider == "openai":
|
||||
return settings.openai_api_key or ""
|
||||
if provider == "gemini":
|
||||
return settings.gemini_api_key or ""
|
||||
return ""
|
||||
|
||||
|
||||
def _env_model_for(provider: str) -> str:
|
||||
if provider == "openai":
|
||||
return settings.openai_model or PROVIDER_DEFAULT_MODELS["openai"]
|
||||
if provider == "gemini":
|
||||
return settings.gemini_model or PROVIDER_DEFAULT_MODELS["gemini"]
|
||||
return PROVIDER_DEFAULT_MODELS.get(provider, "")
|
||||
|
||||
|
||||
async def _resolve(db: AsyncSession) -> tuple[str, str, str, str]:
|
||||
"""Resolve (provider, model, api_key, api_key_source) from DB > env > default."""
|
||||
stored = await _get_settings_map(db)
|
||||
|
||||
provider = (stored.get(KEY_PROVIDER) or "").strip().lower()
|
||||
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"
|
||||
|
||||
model = (stored.get(KEY_MODEL) or "").strip() or _env_model_for(provider)
|
||||
|
||||
db_key = (stored.get(KEY_API_KEY) or "").strip()
|
||||
if db_key:
|
||||
return provider, model, db_key, "database"
|
||||
env_key = _env_key_for(provider)
|
||||
if env_key:
|
||||
return provider, model, env_key, "environment"
|
||||
return provider, model, "", "none"
|
||||
|
||||
|
||||
async def get_sentiment_config(db: AsyncSession) -> dict:
|
||||
"""Public config — never includes the raw API key."""
|
||||
provider, model, api_key, source = await _resolve(db)
|
||||
return {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key_configured": bool(api_key),
|
||||
"api_key_source": source,
|
||||
"valid_providers": sorted(VALID_PROVIDERS),
|
||||
"default_models": PROVIDER_DEFAULT_MODELS,
|
||||
}
|
||||
|
||||
|
||||
async def update_sentiment_config(
|
||||
db: AsyncSession,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> dict:
|
||||
"""Persist provider/model/key. An empty/omitted api_key leaves the stored
|
||||
key untouched (so saving other fields does not wipe credentials)."""
|
||||
if provider is not None:
|
||||
provider = provider.strip().lower()
|
||||
if provider not in VALID_PROVIDERS:
|
||||
raise ValidationError(
|
||||
f"Unknown sentiment provider '{provider}'. Valid: {', '.join(sorted(VALID_PROVIDERS))}"
|
||||
)
|
||||
await update_setting(db, KEY_PROVIDER, provider)
|
||||
|
||||
if model is not None:
|
||||
model = model.strip()
|
||||
if model:
|
||||
await update_setting(db, KEY_MODEL, model)
|
||||
|
||||
if api_key: # only overwrite when a non-empty key is supplied
|
||||
await update_setting(db, KEY_API_KEY, api_key.strip())
|
||||
|
||||
return await get_sentiment_config(db)
|
||||
|
||||
|
||||
async def build_sentiment_provider(db: AsyncSession):
|
||||
"""Construct the active sentiment provider from current config.
|
||||
|
||||
Raises ProviderError if no API key is available for the chosen provider.
|
||||
"""
|
||||
provider, model, api_key, _source = await _resolve(db)
|
||||
if not api_key:
|
||||
raise ProviderError(f"No API key configured for sentiment provider '{provider}'")
|
||||
|
||||
if provider == "openai":
|
||||
from app.providers.openai_sentiment import OpenAISentimentProvider
|
||||
return OpenAISentimentProvider(api_key, model)
|
||||
if provider == "gemini":
|
||||
from app.providers.gemini_sentiment import GeminiSentimentProvider
|
||||
return GeminiSentimentProvider(api_key, model)
|
||||
|
||||
raise ProviderError(f"Unsupported sentiment provider '{provider}'")
|
||||
|
||||
|
||||
async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dict:
|
||||
"""Build the active provider and fetch one ticker as a live credentials check."""
|
||||
provider, model, _key, _source = await _resolve(db)
|
||||
try:
|
||||
prov = await build_sentiment_provider(db)
|
||||
data = await prov.fetch_sentiment(ticker.strip().upper())
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"ticker": ticker.strip().upper(),
|
||||
"classification": data.classification,
|
||||
"confidence": data.confidence,
|
||||
"reasoning": data.reasoning or None,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Sentiment provider test failed: %s", exc)
|
||||
return {
|
||||
"ok": False,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"error": str(exc),
|
||||
}
|
||||
Reference in New Issue
Block a user