From d53ed972d17bf4caeb2bfa294b80d2ee12d0c189 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 13 Jun 2026 11:50:42 +0200 Subject: [PATCH] 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 --- app/routers/admin.py | 38 ++++ app/routers/ingestion.py | 18 +- app/routers/trades.py | 14 +- app/scheduler.py | 15 +- app/schemas/admin.py | 17 +- app/services/admin_service.py | 72 +++++--- app/services/outcome_service.py | 23 ++- app/services/qualification.py | 42 +++++ app/services/sentiment_provider_service.py | 164 ++++++++++++++++++ frontend/src/api/admin.ts | 24 +++ frontend/src/api/performance.ts | 3 +- .../components/admin/ActivationSettings.tsx | 64 ++++++- .../admin/SentimentProviderSettings.tsx | 154 ++++++++++++++++ .../src/components/signals/SetupsPanel.tsx | 44 +++-- .../components/signals/TrackRecordPanel.tsx | 13 +- frontend/src/hooks/useAdmin.ts | 39 ++++- frontend/src/lib/qualification.ts | 33 ++++ frontend/src/lib/types.ts | 26 ++- frontend/src/pages/AdminPage.tsx | 2 + frontend/src/pages/DashboardPage.tsx | 14 +- frontend/tsconfig.tsbuildinfo | 2 +- tests/unit/test_activation_settings.py | 28 ++- tests/unit/test_outcome_service.py | 10 +- tests/unit/test_qualification.py | 81 +++++++++ tests/unit/test_sentiment_provider_service.py | 94 ++++++++++ 25 files changed, 924 insertions(+), 110 deletions(-) create mode 100644 app/services/qualification.py create mode 100644 app/services/sentiment_provider_service.py create mode 100644 frontend/src/components/admin/SentimentProviderSettings.tsx create mode 100644 frontend/src/lib/qualification.ts create mode 100644 tests/unit/test_qualification.py create mode 100644 tests/unit/test_sentiment_provider_service.py diff --git a/app/routers/admin.py b/app/routers/admin.py index 1220665..57d6d46 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -14,6 +14,8 @@ from app.schemas.admin import ( DataCleanupRequest, JobToggle, RecommendationConfigUpdate, + SentimentConfigUpdate, + SentimentTestRequest, PasswordReset, RegistrationToggle, SystemSettingUpdate, @@ -22,6 +24,7 @@ from app.schemas.admin import ( ) from app.schemas.common import APIEnvelope from app.services import admin_service +from app.services import sentiment_provider_service from app.services import ticker_universe_service router = APIRouter(tags=["admin"]) @@ -171,6 +174,41 @@ async def update_activation_settings( return APIEnvelope(status="success", data=updated) +@router.get("/admin/settings/sentiment", response_model=APIEnvelope) +async def get_sentiment_settings( + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + config = await sentiment_provider_service.get_sentiment_config(db) + return APIEnvelope(status="success", data=config) + + +@router.put("/admin/settings/sentiment", response_model=APIEnvelope) +async def update_sentiment_settings( + body: SentimentConfigUpdate, + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + config = await sentiment_provider_service.update_sentiment_config( + db, + provider=body.provider, + model=body.model, + api_key=body.api_key, + ) + return APIEnvelope(status="success", data=config) + + +@router.post("/admin/settings/sentiment/test", response_model=APIEnvelope) +async def test_sentiment_settings( + body: SentimentTestRequest, + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Live credentials check: fetch sentiment for one ticker with current config.""" + result = await sentiment_provider_service.test_sentiment_provider(db, body.ticker) + return APIEnvelope(status="success", data=result) + + @router.get("/admin/settings/ticker-universe", response_model=APIEnvelope) async def get_ticker_universe_setting( _admin: User = Depends(require_admin), diff --git a/app/routers/ingestion.py b/app/routers/ingestion.py index 50e500a..56426fc 100644 --- a/app/routers/ingestion.py +++ b/app/routers/ingestion.py @@ -23,8 +23,8 @@ from app.models.ticker import Ticker from app.models.user import User from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.fundamentals_chain import build_fundamental_provider_chain -from app.providers.openai_sentiment import OpenAISentimentProvider from app.services.rr_scanner_service import scan_ticker +from app.services.sentiment_provider_service import build_sentiment_provider from app.schemas.common import APIEnvelope from app.services import ( fundamental_service, @@ -99,11 +99,14 @@ async def fetch_symbol( sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)} # --- Sentiment --- - if settings.openai_api_key: + try: + sent_provider = await build_sentiment_provider(db) + except ProviderError as exc: + sent_provider = None + sources["sentiment"] = {"status": "skipped", "message": str(exc)} + + if sent_provider is not None: try: - sent_provider = OpenAISentimentProvider( - settings.openai_api_key, settings.openai_model - ) data = await sent_provider.fetch_sentiment(symbol_upper) await sentiment_service.store_sentiment( db, @@ -124,11 +127,6 @@ async def fetch_symbol( except Exception as exc: logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc) sources["sentiment"] = {"status": "error", "message": str(exc)} - else: - sources["sentiment"] = { - "status": "skipped", - "message": "OpenAI API key not configured", - } # --- Fundamentals --- if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key: diff --git a/app/routers/trades.py b/app/routers/trades.py index 0f76ebe..cd78d7e 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -67,9 +67,8 @@ async def get_activation_thresholds( @router.get("/trades/performance", response_model=APIEnvelope) async def get_trade_performance( - min_rr: float | None = Query(None, ge=0, description="Only setups with R:R >= this"), - min_confidence: float | None = Query( - None, ge=0, le=100, description="Only setups with confidence >= this" + qualified_only: bool = Query( + False, description="Restrict overall/direction/action stats to setups that clear the activation gate" ), _user=Depends(require_access), db: AsyncSession = Depends(get_db), @@ -78,11 +77,12 @@ async def get_trade_performance( Outcomes are written by the nightly outcome_evaluator job (win = target hit first, loss = stop hit first, expired = neither within the window). - Optional min_rr / min_confidence filters apply to the overall, direction - and action breakdowns; the confidence breakdown always covers all setups - so thresholds can be validated against it. + With qualified_only, the overall/direction/action breakdowns cover only + setups clearing the activation gate; the confidence breakdown always + covers all setups so the gate can be validated against it. """ - stats = await get_performance_stats(db, min_rr=min_rr, min_confidence=min_confidence) + config = await admin_service.get_activation_config(db) if qualified_only else None + stats = await get_performance_stats(db, config=config) return APIEnvelope(status="success", data=stats) diff --git a/app/scheduler.py b/app/scheduler.py index 02eaf8f..08b6559 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -29,13 +29,14 @@ from app.models.ohlcv import OHLCVRecord from app.models.settings import SystemSetting from app.models.sentiment import SentimentScore from app.models.ticker import Ticker +from app.exceptions import ProviderError from app.providers.alpaca import AlpacaOHLCVProvider from app.providers.fundamentals_chain import build_fundamental_provider_chain -from app.providers.openai_sentiment import OpenAISentimentProvider from app.providers.protocol import SentimentData from app.services import fundamental_service, ingestion_service, sentiment_service from app.services.outcome_service import evaluate_pending_setups from app.services.rr_scanner_service import scan_all_tickers +from app.services.sentiment_provider_service import build_sentiment_provider from app.services.ticker_universe_service import bootstrap_universe logger = logging.getLogger(__name__) @@ -407,13 +408,13 @@ async def collect_sentiment() -> None: total = len(symbols) _runtime_progress(job_name, processed=0, total=total) - if not settings.openai_api_key: - logger.warning(json.dumps({"event": "job_skipped", "job": job_name, "reason": "openai key not configured"})) - _runtime_finish(job_name, "skipped", processed=0, total=total, message="OpenAI key not configured") - return - try: - provider = OpenAISentimentProvider(settings.openai_api_key, settings.openai_model) + async with async_session_factory() as cfg_db: + provider = await build_sentiment_provider(cfg_db) + except ProviderError as exc: + logger.warning(json.dumps({"event": "job_skipped", "job": job_name, "reason": str(exc)})) + _runtime_finish(job_name, "skipped", processed=0, total=total, message=str(exc)) + return except Exception as exc: logger.error(json.dumps({"event": "job_error", "job": job_name, "error_type": type(exc).__name__, "message": str(exc)})) _runtime_finish(job_name, "error", processed=0, total=total, message=str(exc)) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 7a8f95a..c77c995 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -59,6 +59,21 @@ class TickerUniverseUpdate(BaseModel): class ActivationConfigUpdate(BaseModel): - """Activation thresholds: what counts as an actionable signal.""" + """Activation gate: what counts as an actionable signal.""" min_rr: float | None = Field(default=None, ge=0) min_confidence: float | None = Field(default=None, ge=0, le=100) + min_target_probability: float | None = Field(default=None, ge=0, le=100) + require_high_conviction: bool | None = None + exclude_conflicts: bool | None = None + + +class SentimentConfigUpdate(BaseModel): + """Runtime sentiment LLM config. api_key is write-only; omit/empty to keep + the stored key.""" + provider: Literal["openai", "gemini"] | None = None + model: str | None = Field(default=None, max_length=100) + api_key: str | None = Field(default=None, max_length=400) + + +class SentimentTestRequest(BaseModel): + ticker: str = Field(default="AAPL", max_length=10) diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 0c47656..fee453d 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -31,14 +31,29 @@ RECOMMENDATION_CONFIG_DEFAULTS: dict[str, float] = { DEFAULT_TICKER_UNIVERSE = "sp500" SUPPORTED_TICKER_UNIVERSES = {"sp500", "nasdaq100", "nasdaq_all"} -# Activation thresholds: what counts as a signal worth acting on. -# Used as Signals-page default filters, the Dashboard's qualified-setup -# metrics, and the Track Record's "qualified only" view. The outcome -# evaluator deliberately ignores these — every setup gets evaluated so the -# thresholds themselves can be validated against outcomes. -ACTIVATION_DEFAULTS: dict[str, float] = { - "activation_min_rr": 2.0, - "activation_min_confidence": 70.0, +# Activation gate: what counts as a signal worth acting on. Used by the +# Dashboard's "Qualified" metric, the Signals "Qualified only" view, and the +# Track Record's qualified stats. The outcome evaluator deliberately ignores +# these — every setup is evaluated so the gate itself can be validated. +# +# Beyond raw R:R and confidence, the gate demands conviction: a high-conviction +# action (LONG_HIGH / SHORT_HIGH), a clean read (risk Low / no conflicts), and a +# probable primary target. +_ACTIVATION_FLOAT_KEYS: dict[str, str] = { + "min_rr": "activation_min_rr", + "min_confidence": "activation_min_confidence", + "min_target_probability": "activation_min_target_probability", +} +_ACTIVATION_BOOL_KEYS: dict[str, str] = { + "require_high_conviction": "activation_require_high_conviction", + "exclude_conflicts": "activation_exclude_conflicts", +} +ACTIVATION_DEFAULTS: dict[str, float | bool] = { + "min_rr": 2.0, + "min_confidence": 70.0, + "min_target_probability": 60.0, + "require_high_conviction": True, + "exclude_conflicts": True, } @@ -157,40 +172,43 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin # Activation thresholds # --------------------------------------------------------------------------- -async def get_activation_config(db: AsyncSession) -> dict[str, float]: - """Return activation thresholds with public keys (min_rr, min_confidence).""" +async def get_activation_config(db: AsyncSession) -> dict[str, float | bool]: + """Return the activation gate config with public keys.""" result = await db.execute( select(SystemSetting).where(SystemSetting.key.like("activation_%")) ) - config = dict(ACTIVATION_DEFAULTS) - for setting in result.scalars().all(): - if setting.key in config: + stored = {s.key: s.value for s in result.scalars().all()} + + config: dict[str, float | bool] = dict(ACTIVATION_DEFAULTS) + for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items(): + if storage_key in stored: try: - config[setting.key] = float(setting.value) + config[public_key] = float(stored[storage_key]) except (TypeError, ValueError): pass - return { - "min_rr": config["activation_min_rr"], - "min_confidence": config["activation_min_confidence"], - } + for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items(): + if storage_key in stored: + config[public_key] = str(stored[storage_key]).strip().lower() == "true" + return config async def update_activation_config( - db: AsyncSession, updates: dict[str, float] -) -> dict[str, float]: - """Update activation thresholds. Accepts public keys min_rr / min_confidence.""" + db: AsyncSession, updates: dict[str, float | bool] +) -> dict[str, float | bool]: + """Update the activation gate. Accepts public keys; only supplied keys change.""" if "min_rr" in updates and updates["min_rr"] < 0: raise ValidationError("min_rr must be >= 0") if "min_confidence" in updates and not 0 <= updates["min_confidence"] <= 100: raise ValidationError("min_confidence must be between 0 and 100") + if "min_target_probability" in updates and not 0 <= updates["min_target_probability"] <= 100: + raise ValidationError("min_target_probability must be between 0 and 100") - key_map = { - "min_rr": "activation_min_rr", - "min_confidence": "activation_min_confidence", - } - for public_key, storage_key in key_map.items(): - if public_key in updates: + for public_key, storage_key in _ACTIVATION_FLOAT_KEYS.items(): + if public_key in updates and updates[public_key] is not None: await update_setting(db, storage_key, str(float(updates[public_key]))) + for public_key, storage_key in _ACTIVATION_BOOL_KEYS.items(): + if public_key in updates and updates[public_key] is not None: + await update_setting(db, storage_key, "true" if updates[public_key] else "false") return await get_activation_config(db) diff --git a/app/services/outcome_service.py b/app/services/outcome_service.py index 0ba4bb6..42460d0 100644 --- a/app/services/outcome_service.py +++ b/app/services/outcome_service.py @@ -23,6 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.ohlcv import OHLCVRecord from app.models.trade_setup import TradeSetup +from app.services.qualification import setup_qualifies logger = logging.getLogger(__name__) @@ -180,8 +181,7 @@ def _confidence_bucket(score: float | None) -> str | None: async def get_performance_stats( db: AsyncSession, - min_rr: float | None = None, - min_confidence: float | None = None, + config: dict | None = None, ) -> dict: """Aggregate outcome statistics over all evaluated trade setups. @@ -189,9 +189,10 @@ async def get_performance_stats( loss = -1R, expired = 0R). A positive avg_r means the signals have been profitable on a risk-adjusted basis. - min_rr / min_confidence filter the overall, direction and action - breakdowns. The confidence breakdown deliberately stays unfiltered: - it is the instrument for validating the thresholds themselves. + When ``config`` (an activation-gate dict) is supplied, the overall, + direction and action breakdowns cover only qualified setups. The + confidence breakdown deliberately stays unfiltered: it is the + instrument for validating the gate itself. """ result = await db.execute( select(TradeSetup).where(TradeSetup.actual_outcome.is_not(None)) @@ -203,14 +204,10 @@ async def get_performance_stats( ) pending_count = len(pending_result.scalars().all()) - def qualifies(setup: TradeSetup) -> bool: - if min_rr is not None and setup.rr_ratio < min_rr: - return False - if min_confidence is not None and (setup.confidence_score or 0.0) < min_confidence: - return False - return True - - qualified = [s for s in evaluated if qualifies(s)] + if config is not None: + qualified = [s for s in evaluated if setup_qualifies(s, config)] + else: + qualified = evaluated by_direction: dict[str, list[TradeSetup]] = {} by_action: dict[str, list[TradeSetup]] = {} diff --git a/app/services/qualification.py b/app/services/qualification.py new file mode 100644 index 0000000..582dd0c --- /dev/null +++ b/app/services/qualification.py @@ -0,0 +1,42 @@ +"""Shared definition of a 'qualified' (actionable) trade setup. + +A single predicate, driven by the admin activation config, used by the +performance stats (server) and mirrored on the frontend. Beyond raw R:R and +confidence, an actionable setup must show genuine conviction: a high-conviction +recommended action, a clean (conflict-free) read, and a probable primary target. +""" + +from __future__ import annotations + +from typing import Any + +HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"} + + +def best_target_probability(setup: Any) -> float: + """Highest probability among a setup's targets, 0 if none.""" + targets = getattr(setup, "targets", None) or [] + probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)] + return max(probs, default=0.0) + + +def setup_qualifies(setup: Any, config: dict) -> bool: + """Whether a setup clears the activation gate. + + ``setup`` is duck-typed: any object exposing rr_ratio, confidence_score, + recommended_action, risk_level and a ``targets`` list of dicts. + """ + if setup.rr_ratio < config["min_rr"]: + return False + if (setup.confidence_score or 0.0) < config["min_confidence"]: + return False + if config.get("require_high_conviction"): + if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS: + return False + if config.get("exclude_conflicts"): + if (setup.risk_level or "") != "Low": + return False + min_tp = float(config.get("min_target_probability", 0.0)) + if min_tp > 0 and best_target_probability(setup) < min_tp: + return False + return True diff --git a/app/services/sentiment_provider_service.py b/app/services/sentiment_provider_service.py new file mode 100644 index 0000000..73676c1 --- /dev/null +++ b/app/services/sentiment_provider_service.py @@ -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), + } diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 5fcabd8..6aab610 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -4,6 +4,8 @@ import type { AdminUser, PipelineReadiness, RecommendationConfig, + SentimentProviderConfig, + SentimentTestResult, SystemSetting, TickerUniverse, TickerUniverseBootstrapResult, @@ -81,6 +83,28 @@ export function updateActivationSettings(payload: Partial) { .then((r) => r.data); } +export function getSentimentSettings() { + return apiClient + .get('admin/settings/sentiment') + .then((r) => r.data); +} + +export function updateSentimentSettings(payload: { + provider?: string; + model?: string; + api_key?: string; +}) { + return apiClient + .put('admin/settings/sentiment', payload) + .then((r) => r.data); +} + +export function testSentimentSettings(ticker: string) { + return apiClient + .post('admin/settings/sentiment/test', { ticker }) + .then((r) => r.data); +} + export function getTickerUniverseSetting() { return apiClient .get('admin/settings/ticker-universe') diff --git a/frontend/src/api/performance.ts b/frontend/src/api/performance.ts index 512a47c..9d658f7 100644 --- a/frontend/src/api/performance.ts +++ b/frontend/src/api/performance.ts @@ -2,8 +2,7 @@ import apiClient from './client'; import type { PerformanceStats } from '../lib/types'; export interface PerformanceParams { - min_rr?: number; - min_confidence?: number; + qualified_only?: boolean; } export function getPerformance(params?: PerformanceParams) { diff --git a/frontend/src/components/admin/ActivationSettings.tsx b/frontend/src/components/admin/ActivationSettings.tsx index faa0edd..4ac21d6 100644 --- a/frontend/src/components/admin/ActivationSettings.tsx +++ b/frontend/src/components/admin/ActivationSettings.tsx @@ -6,6 +6,9 @@ import { SkeletonTable } from '../ui/Skeleton'; const DEFAULTS: ActivationConfig = { min_rr: 2, min_confidence: 70, + min_target_probability: 60, + require_high_conviction: true, + exclude_conflicts: true, }; export function ActivationSettings() { @@ -19,12 +22,12 @@ export function ActivationSettings() { }, [data]); const onSave = () => { - update.mutate(form as unknown as Record); + update.mutate(form); }; const onReset = () => { setForm(DEFAULTS); - update.mutate(DEFAULTS as unknown as Record); + update.mutate(DEFAULTS); }; if (isLoading) return ; @@ -33,16 +36,16 @@ export function ActivationSettings() { return (
-

Activation Thresholds

+

Activation Gate

- What counts as a signal worth acting on. Used as the default Signals filters, the - Dashboard's qualified-setup metrics, and the Track Record's "qualified only" view. - All setups are still evaluated regardless, so these thresholds can be validated - against the confidence breakdown. + What counts as a signal worth acting on. Drives the Dashboard's "Qualified" metric, the + Signals "Qualified only" view, and the Track Record's qualified stats. All setups are + still evaluated regardless — tighten the gate, then watch qualified expectancy in the + Track Record to find what actually wins.

-
+
+ +
+ +
+ +
diff --git a/frontend/src/components/admin/SentimentProviderSettings.tsx b/frontend/src/components/admin/SentimentProviderSettings.tsx new file mode 100644 index 0000000..05a9eb8 --- /dev/null +++ b/frontend/src/components/admin/SentimentProviderSettings.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { + useSentimentSettings, + useUpdateSentimentSettings, + useTestSentimentProvider, +} from '../../hooks/useAdmin'; +import { Select } from '../ui/Field'; +import { SkeletonTable } from '../ui/Skeleton'; +import type { SentimentTestResult } from '../../lib/types'; + +const SOURCE_LABEL: Record = { + database: 'configured here', + environment: 'from environment (.env)', + none: 'not configured', +}; + +export function SentimentProviderSettings() { + const { data, isLoading, isError, error } = useSentimentSettings(); + const update = useUpdateSentimentSettings(); + const test = useTestSentimentProvider(); + + const [provider, setProvider] = useState('openai'); + const [model, setModel] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [testResult, setTestResult] = useState(null); + + useEffect(() => { + if (data) { + setProvider(data.provider); + setModel(data.model); + } + }, [data]); + + if (isLoading) return ; + if (isError) return

{(error as Error)?.message || 'Failed to load sentiment settings'}

; + if (!data) return null; + + const onProviderChange = (next: string) => { + 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; + if (!model || Object.values(defaults).includes(model)) { + setModel(defaults[next] ?? ''); + } + }; + + const onSave = () => { + setTestResult(null); + update.mutate({ + provider, + model, + ...(apiKey ? { api_key: apiKey } : {}), + }); + setApiKey(''); + }; + + const onTest = () => { + test.mutate('AAPL', { onSuccess: (res) => setTestResult(res) }); + }; + + const keyConfigured = data.api_key_configured; + + return ( +
+
+

Sentiment LLM Provider

+

+ Switch the model that powers sentiment analysis without redeploying. Applies to the + scheduled sentiment job and manual fetches on the next run. +

+
+ +
+ + + +
+ + + +
+ + + Test fetches live sentiment for AAPL with the saved config. +
+ + {testResult && ( +
+ {testResult.ok ? ( + <> + ✓ {testResult.provider} / {testResult.model} — {testResult.ticker}:{' '} + {testResult.classification}{' '} + ({testResult.confidence}%) + {testResult.reasoning &&

{testResult.reasoning}

} + + ) : ( + <> + ✗ Test failed +

{testResult.error}

+ + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/signals/SetupsPanel.tsx b/frontend/src/components/signals/SetupsPanel.tsx index 7bffec9..a683e06 100644 --- a/frontend/src/components/signals/SetupsPanel.tsx +++ b/frontend/src/components/signals/SetupsPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useActivation } from '../../hooks/useActivation'; import { useTrades } from '../../hooks/useTrades'; +import { qualifiesSetup, activationSummary } from '../../lib/qualification'; import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable'; import { SkeletonTable } from '../ui/Skeleton'; import { useToast } from '../ui/Toast'; @@ -99,18 +100,16 @@ export function SetupsPanel() { const queryClient = useQueryClient(); const toast = useToast(); - // null = user hasn't touched the filter; falls back to admin-configured - // activation thresholds once loaded - const [minRROverride, setMinRROverride] = useState(null); - const [minConfidenceOverride, setMinConfidenceOverride] = useState(null); + // "Qualified only" applies the admin activation gate; the manual filters + // below refine within whatever is shown. + const [qualifiedOnly, setQualifiedOnly] = useState(true); + const [minRR, setMinRR] = useState(0); + const [minConfidence, setMinConfidence] = useState(0); const [directionFilter, setDirectionFilter] = useState('both'); const [actionFilter, setActionFilter] = useState('all'); const [sortColumn, setSortColumn] = useState('rr_ratio'); const [sortDirection, setSortDirection] = useState('desc'); - const minRR = minRROverride ?? activation.data?.min_rr ?? 0; - const minConfidence = minConfidenceOverride ?? activation.data?.min_confidence ?? 0; - const scanMutation = useMutation({ mutationFn: () => triggerJob('rr_scanner'), onSuccess: () => { @@ -133,12 +132,35 @@ export function SetupsPanel() { const processed = useMemo(() => { if (!trades) return []; - const filtered = filterTrades(trades, minRR, directionFilter, minConfidence, actionFilter); + let base = trades; + if (qualifiedOnly && activation.data) { + base = base.filter((t) => qualifiesSetup(t, activation.data!)); + } + const filtered = filterTrades(base, minRR, directionFilter, minConfidence, actionFilter); return sortTrades(filtered, sortColumn, sortDirection); - }, [trades, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]); + }, [trades, qualifiedOnly, activation.data, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]); return (
+ {/* Qualified gate toggle */} +
+ + Manual filters below refine within this. +
+ {/* Filter toolbar */}
@@ -150,7 +172,7 @@ export function SetupsPanel() { min={0} step={0.1} value={minRR} - onChange={(e) => setMinRROverride(Number(e.target.value) || 0)} + onChange={(e) => setMinRR(Number(e.target.value) || 0)} className="w-20" />
@@ -174,7 +196,7 @@ export function SetupsPanel() { max={100} step={1} value={minConfidence} - onChange={(e) => setMinConfidenceOverride(Number(e.target.value) || 0)} + onChange={(e) => setMinConfidence(Number(e.target.value) || 0)} className="w-24" /> diff --git a/frontend/src/components/signals/TrackRecordPanel.tsx b/frontend/src/components/signals/TrackRecordPanel.tsx index caad951..4b0149e 100644 --- a/frontend/src/components/signals/TrackRecordPanel.tsx +++ b/frontend/src/components/signals/TrackRecordPanel.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useActivation } from '../../hooks/useActivation'; +import { activationSummary } from '../../lib/qualification'; import { usePerformance } from '../../hooks/usePerformance'; import { triggerJob } from '../../api/admin'; import { Button } from '../ui/Button'; @@ -94,11 +95,9 @@ export function TrackRecordPanel() { const [qualifiedOnly, setQualifiedOnly] = useState(true); const activation = useActivation(); - const params = qualifiedOnly && activation.data - ? { min_rr: activation.data.min_rr, min_confidence: activation.data.min_confidence } - : undefined; - - const { data, isLoading, isError, error } = usePerformance(params); + const { data, isLoading, isError, error } = usePerformance( + qualifiedOnly ? { qualified_only: true } : undefined, + ); const queryClient = useQueryClient(); const toast = useToast(); @@ -126,9 +125,7 @@ export function TrackRecordPanel() { Qualified signals only {activation.data && ( - - R:R ≥ {activation.data.min_rr.toFixed(1)} · conf ≥ {activation.data.min_confidence.toFixed(0)}% - + {activationSummary(activation.data)} )} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index 479b102..a9a6e1e 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -126,13 +126,13 @@ export function useUpdateActivationSettings() { const { addToast } = useToast(); return useMutation({ - mutationFn: (payload: Record) => + mutationFn: (payload: Partial) => adminApi.updateActivationSettings(payload), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'activation-settings'] }); qc.invalidateQueries({ queryKey: ['activation'] }); qc.invalidateQueries({ queryKey: ['performance'] }); - addToast('success', 'Activation thresholds updated'); + addToast('success', 'Activation gate updated'); }, onError: (error: Error) => { addToast('error', error.message || 'Failed to update activation thresholds'); @@ -140,6 +140,41 @@ export function useUpdateActivationSettings() { }); } +export function useSentimentSettings() { + return useQuery({ + queryKey: ['admin', 'sentiment-settings'], + queryFn: () => adminApi.getSentimentSettings(), + }); +} + +export function useUpdateSentimentSettings() { + const qc = useQueryClient(); + const { addToast } = useToast(); + + return useMutation({ + mutationFn: (payload: { provider?: string; model?: string; api_key?: string }) => + adminApi.updateSentimentSettings(payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'sentiment-settings'] }); + addToast('success', 'Sentiment provider updated'); + }, + onError: (error: Error) => { + addToast('error', error.message || 'Failed to update sentiment provider'); + }, + }); +} + +export function useTestSentimentProvider() { + const { addToast } = useToast(); + + return useMutation({ + mutationFn: (ticker: string) => adminApi.testSentimentSettings(ticker), + onError: (error: Error) => { + addToast('error', error.message || 'Sentiment test failed'); + }, + }); +} + export function useTickerUniverseSetting() { return useQuery({ queryKey: ['admin', 'ticker-universe'], diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts new file mode 100644 index 0000000..d3ee08e --- /dev/null +++ b/frontend/src/lib/qualification.ts @@ -0,0 +1,33 @@ +import type { ActivationConfig, TradeSetup } from './types'; + +const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']); + +export function bestTargetProbability(setup: TradeSetup): number { + return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; +} + +/** + * Whether a setup clears the activation gate. Mirrors the backend predicate in + * app/services/qualification.py — keep the two in sync. + */ +export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean { + if (setup.rr_ratio < config.min_rr) return false; + if ((setup.confidence_score ?? 0) < config.min_confidence) return false; + if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) { + return false; + } + if (config.exclude_conflicts && (setup.risk_level ?? '') !== 'Low') return false; + if (config.min_target_probability > 0 && bestTargetProbability(setup) < config.min_target_probability) { + return false; + } + return true; +} + +/** Short human summary of the active gate, e.g. for tooltips/labels. */ +export function activationSummary(config: ActivationConfig): string { + const parts = [`R:R ≥ ${config.min_rr.toFixed(1)}`, `conf ≥ ${config.min_confidence.toFixed(0)}%`]; + if (config.require_high_conviction) parts.push('high-conviction'); + if (config.exclude_conflicts) parts.push('clean'); + if (config.min_target_probability > 0) parts.push(`target ≥ ${config.min_target_probability.toFixed(0)}%`); + return parts.join(' · '); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 367f83b..e4da189 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -152,10 +152,34 @@ export interface PerformanceStats { by_confidence: Record; } -// Activation thresholds: what counts as an actionable signal +// Activation gate: what counts as an actionable signal export interface ActivationConfig { min_rr: number; min_confidence: number; + min_target_probability: number; + require_high_conviction: boolean; + exclude_conflicts: boolean; +} + +// Runtime sentiment LLM configuration +export interface SentimentProviderConfig { + provider: string; + model: string; + api_key_configured: boolean; + api_key_source: 'database' | 'environment' | 'none'; + valid_providers: string[]; + default_models: Record; +} + +export interface SentimentTestResult { + ok: boolean; + provider: string; + model: string; + ticker?: string; + classification?: string; + confidence?: number; + reasoning?: string | null; + error?: string; } export interface TradeTarget { diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 5d4f7af..7e3f029 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { ActivationSettings } from '../components/admin/ActivationSettings'; +import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings'; import { DataCleanup } from '../components/admin/DataCleanup'; import { JobControls } from '../components/admin/JobControls'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; @@ -30,6 +31,7 @@ export default function AdminPage() { {activeTab === 'Settings' && (
+ diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 0bfafde..ecaaac7 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,6 +9,7 @@ import { Section } from '../components/ui/Section'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; import { formatPrice } from '../lib/format'; import { recommendationActionLabel } from '../lib/recommendation'; +import { qualifiesSetup, activationSummary } from '../lib/qualification'; import type { TradeSetup } from '../lib/types'; function fmtR(value: number | null): string { @@ -55,15 +56,12 @@ export default function DashboardPage() { const activation = useActivation(); const performance = usePerformance(); - const minRR = activation.data?.min_rr ?? 2; - const minConfidence = activation.data?.min_confidence ?? 70; - const qualifiedSetups = useMemo( () => - (trades.data ?? []).filter( - (t) => t.rr_ratio >= minRR && (t.confidence_score ?? 0) >= minConfidence, - ), - [trades.data, minRR, minConfidence], + activation.data + ? (trades.data ?? []).filter((t) => qualifiesSetup(t, activation.data!)) + : [], + [trades.data, activation.data], ); // Show qualified setups first; fall back to the full list when none qualify @@ -112,7 +110,7 @@ export default function DashboardPage() { 0 ? 'text-blue-300' : 'text-gray-100'} /> AsyncSession: class TestActivationConfig: async def test_defaults_when_unset(self, session: AsyncSession): config = await get_activation_config(session) - assert config == {"min_rr": 2.0, "min_confidence": 70.0} + assert config == { + "min_rr": 2.0, + "min_confidence": 70.0, + "min_target_probability": 60.0, + "require_high_conviction": True, + "exclude_conflicts": True, + } async def test_update_and_read_back(self, session: AsyncSession): updated = await update_activation_config( session, {"min_rr": 1.5, "min_confidence": 60.0} ) - assert updated == {"min_rr": 1.5, "min_confidence": 60.0} + assert updated["min_rr"] == 1.5 + assert updated["min_confidence"] == 60.0 config = await get_activation_config(session) - assert config == {"min_rr": 1.5, "min_confidence": 60.0} + assert config["min_rr"] == 1.5 + assert config["min_confidence"] == 60.0 async def test_partial_update_keeps_other_value(self, session: AsyncSession): await update_activation_config(session, {"min_confidence": 80.0}) @@ -41,6 +49,16 @@ class TestActivationConfig: assert config["min_rr"] == 2.0 # default untouched assert config["min_confidence"] == 80.0 + async def test_conviction_flags_round_trip(self, session: AsyncSession): + await update_activation_config( + session, + {"require_high_conviction": False, "exclude_conflicts": False, "min_target_probability": 45.0}, + ) + config = await get_activation_config(session) + assert config["require_high_conviction"] is False + assert config["exclude_conflicts"] is False + assert config["min_target_probability"] == 45.0 + async def test_rejects_negative_rr(self, session: AsyncSession): with pytest.raises(ValidationError): await update_activation_config(session, {"min_rr": -1.0}) @@ -48,3 +66,7 @@ class TestActivationConfig: async def test_rejects_out_of_range_confidence(self, session: AsyncSession): with pytest.raises(ValidationError): await update_activation_config(session, {"min_confidence": 120.0}) + + async def test_rejects_out_of_range_target_probability(self, session: AsyncSession): + with pytest.raises(ValidationError): + await update_activation_config(session, {"min_target_probability": 150.0}) diff --git a/tests/unit/test_outcome_service.py b/tests/unit/test_outcome_service.py index cd31d7f..4bdcef4 100644 --- a/tests/unit/test_outcome_service.py +++ b/tests/unit/test_outcome_service.py @@ -289,7 +289,15 @@ class TestGetPerformanceStats: )) await db_session.flush() - stats = await get_performance_stats(db_session, min_rr=2.0, min_confidence=70.0) + # Gate on R:R + confidence only (conviction filters off for this test) + config = { + "min_rr": 2.0, + "min_confidence": 70.0, + "min_target_probability": 0.0, + "require_high_conviction": False, + "exclude_conflicts": False, + } + stats = await get_performance_stats(db_session, config=config) # Overall covers only the qualified setup assert stats["overall"]["total"] == 1 diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py new file mode 100644 index 0000000..989d9e7 --- /dev/null +++ b/tests/unit/test_qualification.py @@ -0,0 +1,81 @@ +"""Unit tests for the activation qualification predicate.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from app.services.qualification import best_target_probability, setup_qualifies + +FULL_GATE = { + "min_rr": 2.0, + "min_confidence": 70.0, + "min_target_probability": 60.0, + "require_high_conviction": True, + "exclude_conflicts": True, +} + + +def _setup(**kwargs): + base = dict( + rr_ratio=3.0, + confidence_score=80.0, + recommended_action="LONG_HIGH", + risk_level="Low", + targets=[{"probability": 65.0}], + ) + base.update(kwargs) + return SimpleNamespace(**base) + + +class TestSetupQualifies: + def test_clean_high_conviction_setup_passes(self): + assert setup_qualifies(_setup(), FULL_GATE) is True + + def test_low_rr_fails(self): + assert setup_qualifies(_setup(rr_ratio=1.5), FULL_GATE) is False + + def test_low_confidence_fails(self): + assert setup_qualifies(_setup(confidence_score=60.0), FULL_GATE) is False + + def test_moderate_action_fails_when_high_conviction_required(self): + assert setup_qualifies(_setup(recommended_action="LONG_MODERATE"), FULL_GATE) is False + + def test_neutral_action_fails(self): + assert setup_qualifies(_setup(recommended_action="NEUTRAL"), FULL_GATE) is False + + def test_short_high_passes(self): + assert setup_qualifies(_setup(recommended_action="SHORT_HIGH"), FULL_GATE) is True + + def test_non_low_risk_fails_when_excluding_conflicts(self): + assert setup_qualifies(_setup(risk_level="Medium"), FULL_GATE) is False + assert setup_qualifies(_setup(risk_level="High"), FULL_GATE) is False + + def test_low_target_probability_fails(self): + assert setup_qualifies(_setup(targets=[{"probability": 40.0}]), FULL_GATE) is False + + def test_no_targets_fails_when_probability_required(self): + assert setup_qualifies(_setup(targets=[]), FULL_GATE) is False + + def test_conviction_filters_can_be_disabled(self): + relaxed = { + "min_rr": 2.0, + "min_confidence": 70.0, + "min_target_probability": 0.0, + "require_high_conviction": False, + "exclude_conflicts": False, + } + # Moderate action, medium risk, no targets — still passes on rr+confidence alone + s = _setup(recommended_action="LONG_MODERATE", risk_level="Medium", targets=[]) + assert setup_qualifies(s, relaxed) is True + + def test_missing_confidence_treated_as_zero(self): + assert setup_qualifies(_setup(confidence_score=None), FULL_GATE) is False + + +class TestBestTargetProbability: + def test_returns_max(self): + s = _setup(targets=[{"probability": 40.0}, {"probability": 72.0}, {"probability": 55.0}]) + assert best_target_probability(s) == 72.0 + + def test_empty_is_zero(self): + assert best_target_probability(_setup(targets=[])) == 0.0 diff --git a/tests/unit/test_sentiment_provider_service.py b/tests/unit/test_sentiment_provider_service.py new file mode 100644 index 0000000..3703271 --- /dev/null +++ b/tests/unit/test_sentiment_provider_service.py @@ -0,0 +1,94 @@ +"""Unit tests for runtime sentiment provider configuration.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.exceptions import ProviderError, ValidationError +from app.models.settings import SystemSetting +from app.services import sentiment_provider_service as sps + + +@pytest.fixture +async def session() -> AsyncSession: + from tests.conftest import _test_session_factory + + async with _test_session_factory() as session: + yield session + + +@pytest.fixture(autouse=True) +def _clear_env_keys(monkeypatch): + """Default: no env keys, so DB-only behavior is deterministic.""" + monkeypatch.setattr(sps.settings, "openai_api_key", "") + monkeypatch.setattr(sps.settings, "gemini_api_key", "") + + +class TestGetConfig: + async def test_defaults_no_key(self, session: AsyncSession): + config = await sps.get_sentiment_config(session) + assert config["provider"] == "openai" + assert config["model"] == "gpt-4o-mini" + assert config["api_key_configured"] is False + assert config["api_key_source"] == "none" + assert set(config["valid_providers"]) == {"openai", "gemini"} + + async def test_never_returns_raw_key(self, session: AsyncSession): + await sps.update_sentiment_config(session, api_key="sk-secret-123") + config = await sps.get_sentiment_config(session) + # No field should leak the key + assert "sk-secret-123" not in str(config) + assert config["api_key_configured"] is True + assert config["api_key_source"] == "database" + + async def test_env_fallback_reported(self, session: AsyncSession, monkeypatch): + monkeypatch.setattr(sps.settings, "openai_api_key", "sk-from-env") + config = await sps.get_sentiment_config(session) + assert config["api_key_configured"] is True + assert config["api_key_source"] == "environment" + + +class TestUpdateConfig: + async def test_update_provider_and_model(self, session: AsyncSession): + result = await sps.update_sentiment_config( + session, provider="gemini", model="gemini-2.0-flash" + ) + assert result["provider"] == "gemini" + assert result["model"] == "gemini-2.0-flash" + + async def test_empty_key_does_not_overwrite(self, session: AsyncSession): + await sps.update_sentiment_config(session, api_key="sk-original") + # Subsequent save without a key must keep the original + await sps.update_sentiment_config(session, provider="openai", api_key="") + result = await session.execute( + select(SystemSetting).where(SystemSetting.key == sps.KEY_API_KEY) + ) + assert result.scalar_one().value == "sk-original" + + async def test_rejects_invalid_provider(self, session: AsyncSession): + with pytest.raises(ValidationError): + await sps.update_sentiment_config(session, provider="anthropic") + + +class TestBuildProvider: + async def test_raises_without_key(self, session: AsyncSession): + with pytest.raises(ProviderError): + await sps.build_sentiment_provider(session) + + async def test_builds_openai(self, session: AsyncSession): + await sps.update_sentiment_config(session, provider="openai", api_key="sk-x") + provider = await sps.build_sentiment_provider(session) + assert type(provider).__name__ == "OpenAISentimentProvider" + + async def test_builds_gemini(self, session: AsyncSession): + await sps.update_sentiment_config(session, provider="gemini", api_key="g-x") + provider = await sps.build_sentiment_provider(session) + assert type(provider).__name__ == "GeminiSentimentProvider" + + async def test_uses_env_key_when_db_empty(self, session: AsyncSession, monkeypatch): + monkeypatch.setattr(sps.settings, "openai_api_key", "sk-env") + await sps.update_sentiment_config(session, provider="openai") + provider = await sps.build_sentiment_provider(session) + assert type(provider).__name__ == "OpenAISentimentProvider"