Add Admin control for fundamentals cutover
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m48s
Deploy / deploy (push) Successful in 40s

This commit is contained in:
2026-07-24 16:15:51 +02:00
parent b0537ebe9a
commit 49bf3b140e
15 changed files with 377 additions and 19 deletions
+22
View File
@@ -13,6 +13,7 @@ from app.schemas.admin import (
AlertConfigUpdate, AlertConfigUpdate,
CreateUserRequest, CreateUserRequest,
DataCleanupRequest, DataCleanupRequest,
FundamentalsCutoverConfigUpdate,
JobTriggerRequest, JobTriggerRequest,
JobToggle, JobToggle,
RecommendationConfigUpdate, RecommendationConfigUpdate,
@@ -137,6 +138,27 @@ async def list_settings(
) )
@router.get("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
async def get_fundamentals_cutover_settings(
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.get_fundamentals_cutover_config(db)
return APIEnvelope(status="success", data=config)
@router.put("/admin/settings/fundamentals-cutover", response_model=APIEnvelope)
async def update_fundamentals_cutover_settings(
body: FundamentalsCutoverConfigUpdate,
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
config = await admin_service.update_fundamentals_cutover_config(
db, body.enabled
)
return APIEnvelope(status="success", data=config)
@router.get("/admin/settings/recommendations", response_model=APIEnvelope) @router.get("/admin/settings/recommendations", response_model=APIEnvelope)
async def get_recommendation_settings( async def get_recommendation_settings(
_admin: User = Depends(require_admin), _admin: User = Depends(require_admin),
+16
View File
@@ -826,6 +826,22 @@ async def collect_fundamentals() -> None:
_log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled") _log_event(logging.INFO, "job_skipped", job=job_name, reason="disabled")
_runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled") _runtime_finish(job_name, "skipped", processed=0, total=0, message="Disabled")
return return
if await fundamental_data_refresh_service.is_enabled(db):
message = "SEC + Dolt fundamentals cutover is active"
_log_event(
logging.INFO,
"job_skipped",
job=job_name,
reason="sec_dolt_cutover_active",
)
_runtime_finish(
job_name,
"skipped",
processed=0,
total=0,
message=message,
)
return
symbols = await _get_fundamental_priority_tickers(db) symbols = await _get_fundamental_priority_tickers(db)
if not symbols: if not symbols:
+5
View File
@@ -73,6 +73,11 @@ class ActivationConfigUpdate(BaseModel):
exclude_neutral: bool | None = None exclude_neutral: bool | None = None
class FundamentalsCutoverConfigUpdate(BaseModel):
"""Switch the legacy fundamentals cache from quota APIs to SEC/Dolt."""
enabled: bool
class ScheduleConfigUpdate(BaseModel): class ScheduleConfigUpdate(BaseModel):
"""Cron schedule for the pipelines + fundamentals. Crons are 5-field """Cron schedule for the pipelines + fundamentals. Crons are 5-field
(min hour dom month dow); timezone is an IANA name (e.g. America/New_York).""" (min hour dom month dow); timezone is an IANA name (e.g. America/New_York)."""
+23 -1
View File
@@ -17,7 +17,7 @@ from app.models.settings import SystemSetting
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.models.user import User from app.models.user import User
from app.services import settings_store from app.services import fundamental_data_refresh_service, settings_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,6 +159,28 @@ async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSettin
return setting return setting
# ---------------------------------------------------------------------------
# Fundamentals source cutover
# ---------------------------------------------------------------------------
async def get_fundamentals_cutover_config(db: AsyncSession) -> dict[str, bool]:
"""Return the explicit A5 cache-cutover switch (default off)."""
return {"enabled": await fundamental_data_refresh_service.is_enabled(db)}
async def update_fundamentals_cutover_config(
db: AsyncSession, enabled: bool
) -> dict[str, bool]:
"""Activate or pause SEC/Dolt writes to the legacy fundamentals cache."""
await settings_store.upsert_setting(
db,
fundamental_data_refresh_service.ACTIVATION_KEY,
"true" if enabled else "false",
)
await db.commit()
return await get_fundamentals_cutover_config(db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Activation thresholds # Activation thresholds
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+4 -2
View File
@@ -502,8 +502,10 @@ that path carries the split guard (`ttm_diluted_eps`
nulls when contaminated, with `ttm_diluted_eps_caveat`) and the multi-class share nulls when contaminated, with `ttm_diluted_eps_caveat`) and the multi-class share
fallback (`shares_outstanding` + `shares_outstanding_estimated`). Parity and activation fallback (`shares_outstanding` + `shares_outstanding_estimated`). Parity and activation
share the same candidate builder. Activation is the explicit share the same candidate builder. Activation is the explicit
`fundamental_data_sec_dolt_cutover_enabled` SystemSetting and defaults off; see `fundamental_data_sec_dolt_cutover_enabled` SystemSetting and defaults off. It is
`docs/fundamentals-deployment.md` for the production flip and rollback procedure. managed by the **Fundamentals data source** card in Admin → Settings; while active,
the weekly legacy collector skips itself so it cannot overwrite the SEC/Dolt cache.
See `docs/fundamentals-deployment.md` for the production flip and rollback procedure.
**Task 2 — A6 decommissioning.** After a short observation window: remove **Task 2 — A6 decommissioning.** After a short observation window: remove
FMP/Finnhub/Alpha Vantage providers, config and env keys; keep monitoring + manual FMP/Finnhub/Alpha Vantage providers, config and env keys; keep monitoring + manual
+19 -9
View File
@@ -163,7 +163,17 @@ The write path is controlled by the SystemSetting
value other than `true` leaves `fundamental_data` untouched. Before enabling it, value other than `true` leaves `fundamental_data` untouched. Before enabling it,
confirm the normal PostgreSQL backup containing `fundamental_data` is current. confirm the normal PostgreSQL backup containing `fundamental_data` is current.
Enable the cutover in PostgreSQL: In **Admin → Settings → Fundamentals data source**:
1. Turn on **Use SEC + Dolt for scoring inputs** and accept the confirmation.
2. Click **Run refresh now**. The SEC import may be `promoted` or `no_op`; either
result runs the local cache refresh.
The weekly legacy collector is automatically skipped while the switch is on, so
it cannot overwrite the activated cache. The switch remains visible even before
its SystemSetting row exists because the safe default is off.
If the Admin UI is unavailable, enable the cutover directly in PostgreSQL:
```sql ```sql
INSERT INTO system_settings (key, value, updated_at) INSERT INTO system_settings (key, value, updated_at)
@@ -172,8 +182,7 @@ ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = now(); SET value = EXCLUDED.value, updated_at = now();
``` ```
Then trigger **SEC Fundamentals Import** once in Admin → Jobs. The import may be Then trigger **SEC Fundamentals Import** once in Admin → Jobs. Once enabled, the
`promoted` or `no_op`; either result runs the local refresh. Once enabled, the
same refresh also runs after an SEC network/validation failure or a source-lock same refresh also runs after an SEC network/validation failure or a source-lock
skip, because it reads only PostgreSQL snapshots, earnings events, and closes. skip, because it reads only PostgreSQL snapshots, earnings events, and closes.
The job message appends the cache row count and changed score-input count when The job message appends the cache row count and changed score-input count when
@@ -211,12 +220,13 @@ least several scheduled cycles before A6 removes the legacy providers.
## Failure and rollback ## Failure and rollback
- To stop the A5 cache writes without stopping SEC snapshot ingestion, set - To stop the A5 cache writes without stopping SEC snapshot ingestion, turn off
`fundamental_data_sec_dolt_cutover_enabled` back to `false` with the SQL above **Use SEC + Dolt for scoring inputs** in Admin → Settings. If the UI is
(changing only the value). This prevents the next local refresh but does not unavailable, set `fundamental_data_sec_dolt_cutover_enabled` back to `false`
restore rows already replaced. Restore `fundamental_data` from the pre-cutover with the SQL above (changing only the value). This prevents the next local
database backup, or—before A6—manually run the legacy Fundamental Collector if refresh but does not restore rows already replaced. Restore `fundamental_data`
its provider keys and quota are still available. from the pre-cutover database backup, or—before A6—manually run the legacy
Fundamental Collector if its provider keys and quota are still available.
- Disable a failing source-import job in Admin → Jobs only when ingestion itself - Disable a failing source-import job in Admin → Jobs only when ingestion itself
must stop. Existing promoted snapshots/events remain available. must stop. Existing promoted snapshots/events remain available.
- Inspect the job runtime, latest `data_import_runs.validation_json`, service - Inspect the job runtime, latest `data_import_runs.validation_json`, service
+13
View File
@@ -4,6 +4,7 @@ import type {
AdminUser, AdminUser,
AlertConfig, AlertConfig,
AlertTestResult, AlertTestResult,
FundamentalsCutoverConfig,
PipelineReadiness, PipelineReadiness,
RecommendationConfig, RecommendationConfig,
ScheduleConfig, ScheduleConfig,
@@ -56,6 +57,18 @@ export function updateSetting(key: string, value: string) {
.then((r) => r.data); .then((r) => r.data);
} }
export function getFundamentalsCutoverSettings() {
return apiClient
.get<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover')
.then((r) => r.data);
}
export function updateFundamentalsCutoverSettings(enabled: boolean) {
return apiClient
.put<FundamentalsCutoverConfig>('admin/settings/fundamentals-cutover', { enabled })
.then((r) => r.data);
}
export function getRecommendationSettings() { export function getRecommendationSettings() {
return apiClient return apiClient
.get<RecommendationConfig>('admin/settings/recommendations') .get<RecommendationConfig>('admin/settings/recommendations')
@@ -0,0 +1,176 @@
import {
useFundamentalsCutoverSettings,
useJobs,
useTriggerJob,
useUpdateFundamentalsCutoverSettings,
} from '../../hooks/useAdmin';
import { SkeletonCard } from '../ui/Skeleton';
const SEC_JOB = 'sec_fundamentals_import';
function formatRun(iso: string | null | undefined): string {
if (!iso) return 'not run in this process';
const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
}
export function FundamentalsCutoverSettings() {
const cutover = useFundamentalsCutoverSettings();
const update = useUpdateFundamentalsCutoverSettings();
const trigger = useTriggerJob();
const { data: jobs } = useJobs();
if (cutover.isLoading) return <SkeletonCard />;
if (cutover.isError || !cutover.data) {
return (
<p className="text-sm text-red-400">
{(cutover.error as Error)?.message || 'Failed to load fundamentals data source'}
</p>
);
}
const enabled = cutover.data.enabled;
const secJob = jobs?.find((job) => job.name === SEC_JOB);
const runningJob = jobs?.find((job) => job.running);
const refreshBlocked = Boolean(runningJob && runningJob.name !== SEC_JOB);
const changeSource = () => {
const next = !enabled;
const confirmed = window.confirm(
next
? 'Activate SEC + Dolt fundamentals? The next SEC import will replace the legacy cache and mark affected scores stale.'
: 'Pause SEC + Dolt cache refreshes? Existing cache values will stay in place; legacy values are not restored automatically.',
);
if (confirmed) update.mutate(next);
};
return (
<section className="glass overflow-hidden" aria-labelledby="fundamentals-source-title">
<div className={`h-0.5 ${enabled ? 'bg-gradient-to-r from-sky-500 via-cyan-300 to-emerald-400' : 'bg-white/[0.06]'}`} />
<div className="space-y-5 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 id="fundamentals-source-title" className="text-sm font-semibold text-gray-200">
Fundamentals data source
</h3>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${
enabled
? 'border-cyan-400/25 bg-cyan-400/10 text-cyan-300'
: 'border-white/10 bg-white/[0.04] text-gray-500'
}`}
>
{enabled ? 'SEC + Dolt active' : 'Legacy cache'}
</span>
</div>
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-gray-500">
Controls what repopulates <span className="num text-gray-400">fundamental_data</span>, the
compatibility cache used by scoring. SEC filings supply P/E, growth and estimated market
cap; Dolt supplies earnings dates and surprises. Everything is derived locally from PostgreSQL.
</p>
</div>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_5rem_minmax(0,1fr)] items-center gap-3 rounded-xl border border-white/[0.06] bg-black/10 px-4 py-3">
<div className={enabled ? 'text-gray-600' : 'text-amber-200/90'}>
<div className="num text-[10px] uppercase tracking-[0.16em]">Legacy APIs</div>
<div className="mt-0.5 text-[11px]">FMP / Finnhub / Alpha Vantage</div>
</div>
<div className="relative h-px bg-white/10" aria-hidden="true">
<span
className={`absolute top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border-2 border-[#0e120f] transition-all duration-300 ${
enabled
? 'right-0 bg-cyan-300 shadow-[0_0_12px_rgba(103,232,249,0.55)]'
: 'left-0 bg-amber-300'
}`}
/>
</div>
<div className={`text-right ${enabled ? 'text-cyan-200' : 'text-gray-600'}`}>
<div className="num text-[10px] uppercase tracking-[0.16em]">SEC + Dolt</div>
<div className="mt-0.5 text-[11px]">Bulk imports PostgreSQL cache</div>
</div>
</div>
<div className="grid gap-4 border-t border-white/[0.06] pt-4 md:grid-cols-2">
<div className="flex items-start justify-between gap-4 rounded-xl bg-white/[0.025] p-3.5">
<div>
<div className="num text-[10px] uppercase tracking-[0.14em] text-gray-600">1 · Source</div>
<div className="mt-1 text-sm text-gray-200">Use SEC + Dolt for scoring inputs</div>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
While active, the weekly legacy collector is skipped so it cannot overwrite the new cache.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Use SEC and Dolt fundamentals"
onClick={changeSource}
disabled={update.isPending}
className={`relative mt-1 inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-400/70 focus:ring-offset-2 focus:ring-offset-[#0e120f] disabled:cursor-wait disabled:opacity-50 ${
enabled ? 'bg-gradient-to-r from-sky-500 to-cyan-400' : 'bg-white/10'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<div className="rounded-xl bg-white/[0.025] p-3.5">
<div className="num text-[10px] uppercase tracking-[0.14em] text-gray-600">2 · Refresh</div>
<div className="mt-1 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm text-gray-200">Apply the source now</div>
<p className="mt-1 text-[11px] text-gray-500">
{secJob?.running
? 'SEC import and cache refresh are running.'
: secJob?.runtime_message || `Last SEC run: ${formatRun(secJob?.runtime_finished_at)}`}
</p>
</div>
<button
type="button"
onClick={() => trigger.mutate(SEC_JOB)}
disabled={
!enabled ||
trigger.isPending ||
Boolean(secJob?.running) ||
refreshBlocked ||
secJob?.enabled === false
}
className="btn-primary px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-40"
>
<span>
{secJob?.running
? 'Refreshing…'
: trigger.isPending
? 'Starting…'
: refreshBlocked
? 'Another job is running'
: 'Run refresh now'}
</span>
</button>
</div>
{!enabled && (
<p className="mt-2 text-[11px] text-amber-300/70">Activate the source before running the refresh.</p>
)}
{enabled && secJob?.enabled === false && (
<p className="mt-2 text-[11px] text-amber-300/70">Enable the SEC Fundamentals job on the Jobs tab first.</p>
)}
</div>
</div>
<p className="text-[11px] leading-relaxed text-gray-600">
Rollback pauses future writes only. To restore pre-cutover values, use the database backup or
pause this source and manually run the legacy collector while its provider keys remain installed.
</p>
</div>
</section>
);
}
@@ -29,20 +29,20 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
}, },
{ {
key: 'schedule_dolt_earnings_cron', key: 'schedule_dolt_earnings_cron',
label: 'Dolt earnings (shadow)', label: 'Dolt earnings',
hint: 'Pull and import earnings dates/results daily at 02:30 ET. Live scoring remains untouched before A5.', hint: 'Pull and import earnings dates/results daily at 02:30 ET. The activated cache refresh uses these local events.',
mono: true, mono: true,
}, },
{ {
key: 'schedule_sec_fundamentals_cron', key: 'schedule_sec_fundamentals_cron',
label: 'SEC fundamentals (shadow)', label: 'SEC fundamentals',
hint: 'Import tracked-universe SEC facts daily at 04:00 ET. Unchanged revisions become no-op runs.', hint: 'Import tracked-universe SEC facts daily at 04:00 ET and refresh the scoring cache when the cutover is active.',
mono: true, mono: true,
}, },
{ {
key: 'schedule_fundamentals_parity_cron', key: 'schedule_fundamentals_parity_cron',
label: 'Fundamentals parity report', label: 'Fundamentals parity report',
hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the shadow imports.', hint: 'Read-only legacy vs SEC/Dolt comparison daily at 05:30 ET, after the bulk imports.',
mono: true, mono: true,
}, },
{ {
@@ -66,7 +66,7 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
{ {
key: 'schedule_fundamentals_cron', key: 'schedule_fundamentals_cron',
label: 'Legacy fundamentals (weekly)', label: 'Legacy fundamentals (weekly)',
hint: 'Existing provider chain retained until the A5 parity approval and A6 removal.', hint: 'Fallback provider chain. Automatically skipped while the SEC + Dolt cutover is active.',
mono: true, mono: true,
}, },
]; ];
@@ -3,6 +3,8 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types'; import type { SystemSetting } from '../../lib/types';
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
export function SettingsForm() { export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings(); const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting(); const updateSetting = useUpdateSetting();
@@ -32,10 +34,11 @@ export function SettingsForm() {
if (isLoading) return <SkeletonTable rows={4} cols={2} />; if (isLoading) return <SkeletonTable rows={4} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>; if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load settings'}</p>;
if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>; if (!settings || settings.length === 0) return <p className="text-sm text-gray-500">No settings found.</p>;
const visibleSettings = settings.filter((setting) => !MANAGED_SETTINGS.has(setting.key));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{settings.map((setting) => ( {visibleSettings.map((setting) => (
<div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover"> <div key={setting.key} className="glass p-4 flex flex-wrap items-center gap-3 glass-hover">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label> <label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{setting.key === 'registration' ? ( {setting.key === 'registration' ? (
+30
View File
@@ -90,6 +90,36 @@ export function useUpdateSetting() {
}); });
} }
export function useFundamentalsCutoverSettings() {
return useQuery({
queryKey: ['admin', 'fundamentals-cutover'],
queryFn: () => adminApi.getFundamentalsCutoverSettings(),
});
}
export function useUpdateFundamentalsCutoverSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (enabled: boolean) =>
adminApi.updateFundamentalsCutoverSettings(enabled),
onSuccess: (config) => {
qc.setQueryData(['admin', 'fundamentals-cutover'], config);
qc.invalidateQueries({ queryKey: ['admin', 'settings'] });
addToast(
config.enabled ? 'success' : 'info',
config.enabled
? 'SEC + Dolt fundamentals activated'
: 'SEC + Dolt cache refresh paused',
);
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update fundamentals data source');
},
});
}
export function useRecommendationSettings() { export function useRecommendationSettings() {
return useQuery({ return useQuery({
queryKey: ['admin', 'recommendation-settings'], queryKey: ['admin', 'recommendation-settings'],
+4
View File
@@ -187,6 +187,10 @@ export interface ActivationConfig {
exclude_neutral: boolean; exclude_neutral: boolean;
} }
export interface FundamentalsCutoverConfig {
enabled: boolean;
}
// Cron schedule for morning / near-close / after-close / intraday + fundamentals // Cron schedule for morning / near-close / after-close / intraday + fundamentals
export interface ScheduleConfig { export interface ScheduleConfig {
schedule_timezone: string; schedule_timezone: string;
+2
View File
@@ -6,6 +6,7 @@ import { SentimentProviderSettings } from '../components/admin/SentimentProvider
import { DataCleanup } from '../components/admin/DataCleanup'; import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls'; import { JobControls } from '../components/admin/JobControls';
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel'; import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
import { FundamentalsCutoverSettings } from '../components/admin/FundamentalsCutoverSettings';
import { PerformanceSettings } from '../components/admin/PerformanceSettings'; import { PerformanceSettings } from '../components/admin/PerformanceSettings';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel'; import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel'; import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
@@ -36,6 +37,7 @@ export default function AdminPage() {
{activeTab === 'Tickers' && <TickerManagement />} {activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && ( {activeTab === 'Settings' && (
<div className="space-y-4"> <div className="space-y-4">
<FundamentalsCutoverSettings />
<ActivationSettings /> <ActivationSettings />
<ExitPolicySettings /> <ExitPolicySettings />
<PerformanceSettings /> <PerformanceSettings />
+17
View File
@@ -8,7 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import ValidationError from app.exceptions import ValidationError
from app.services.admin_service import ( from app.services.admin_service import (
get_activation_config, get_activation_config,
get_fundamentals_cutover_config,
update_activation_config, update_activation_config,
update_fundamentals_cutover_config,
) )
@@ -76,3 +78,18 @@ class TestActivationConfig:
async def test_rejects_out_of_range_confidence(self, session: AsyncSession): async def test_rejects_out_of_range_confidence(self, session: AsyncSession):
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
await update_activation_config(session, {"min_confidence": 120.0}) await update_activation_config(session, {"min_confidence": 120.0})
class TestFundamentalsCutoverConfig:
async def test_defaults_off_when_unset(self, session: AsyncSession):
assert await get_fundamentals_cutover_config(session) == {"enabled": False}
async def test_round_trips_explicit_switch(self, session: AsyncSession):
assert await update_fundamentals_cutover_config(session, True) == {
"enabled": True
}
assert await get_fundamentals_cutover_config(session) == {"enabled": True}
assert await update_fundamentals_cutover_config(session, False) == {
"enabled": False
}
+36
View File
@@ -11,6 +11,7 @@ from app.scheduler import (
_resume_tickers, _resume_tickers,
_last_successful, _last_successful,
_run_shadow_import, _run_shadow_import,
collect_fundamentals,
run_fundamentals_parity_report, run_fundamentals_parity_report,
run_sec_fundamentals_import, run_sec_fundamentals_import,
configure_scheduler, configure_scheduler,
@@ -169,6 +170,41 @@ class _SessionContext:
return None return None
class TestFundamentalCollector:
@staticmethod
def _session_factory():
return _SessionContext()
async def test_skips_legacy_provider_when_cutover_is_active(self, monkeypatch):
async def enabled(db, job_name):
return True
async def cutover_enabled(db):
return True
async def unexpected_ticker_lookup(db):
raise AssertionError("legacy ticker lookup must not run after cutover")
monkeypatch.setattr("app.scheduler.async_session_factory", self._session_factory)
monkeypatch.setattr("app.scheduler._is_job_enabled", enabled)
monkeypatch.setattr(
"app.scheduler.fundamental_data_refresh_service.is_enabled",
cutover_enabled,
)
monkeypatch.setattr(
"app.scheduler._get_fundamental_priority_tickers",
unexpected_ticker_lookup,
)
await collect_fundamentals()
runtime = get_job_runtime_snapshot("fundamental_collector")
assert runtime["status"] == "skipped"
assert runtime["processed"] == 0
assert runtime["total"] == 0
assert runtime["message"] == "SEC + Dolt fundamentals cutover is active"
class TestShadowImportJobs: class TestShadowImportJobs:
@staticmethod @staticmethod
def _session_factory(): def _session_factory():