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
+13
View File
@@ -4,6 +4,7 @@ import type {
AdminUser,
AlertConfig,
AlertTestResult,
FundamentalsCutoverConfig,
PipelineReadiness,
RecommendationConfig,
ScheduleConfig,
@@ -56,6 +57,18 @@ export function updateSetting(key: string, value: string) {
.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() {
return apiClient
.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',
label: 'Dolt earnings (shadow)',
hint: 'Pull and import earnings dates/results daily at 02:30 ET. Live scoring remains untouched before A5.',
label: 'Dolt earnings',
hint: 'Pull and import earnings dates/results daily at 02:30 ET. The activated cache refresh uses these local events.',
mono: true,
},
{
key: 'schedule_sec_fundamentals_cron',
label: 'SEC fundamentals (shadow)',
hint: 'Import tracked-universe SEC facts daily at 04:00 ET. Unchanged revisions become no-op runs.',
label: 'SEC fundamentals',
hint: 'Import tracked-universe SEC facts daily at 04:00 ET and refresh the scoring cache when the cutover is active.',
mono: true,
},
{
key: 'schedule_fundamentals_parity_cron',
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,
},
{
@@ -66,7 +66,7 @@ const FIELDS: { key: keyof ScheduleConfig; label: string; hint: string; mono?: b
{
key: 'schedule_fundamentals_cron',
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,
},
];
@@ -3,6 +3,8 @@ import { useSettings, useUpdateSetting } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import type { SystemSetting } from '../../lib/types';
const MANAGED_SETTINGS = new Set(['fundamental_data_sec_dolt_cutover_enabled']);
export function SettingsForm() {
const { data: settings, isLoading, isError, error } = useSettings();
const updateSetting = useUpdateSetting();
@@ -32,10 +34,11 @@ export function SettingsForm() {
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 (!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 (
<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">
<label className="min-w-[140px] text-sm font-medium text-gray-300">{setting.key}</label>
{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() {
return useQuery({
queryKey: ['admin', 'recommendation-settings'],
+4
View File
@@ -187,6 +187,10 @@ export interface ActivationConfig {
exclude_neutral: boolean;
}
export interface FundamentalsCutoverConfig {
enabled: boolean;
}
// Cron schedule for morning / near-close / after-close / intraday + fundamentals
export interface ScheduleConfig {
schedule_timezone: string;
+2
View File
@@ -6,6 +6,7 @@ import { SentimentProviderSettings } from '../components/admin/SentimentProvider
import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
import { FundamentalsParityPanel } from '../components/admin/FundamentalsParityPanel';
import { FundamentalsCutoverSettings } from '../components/admin/FundamentalsCutoverSettings';
import { PerformanceSettings } from '../components/admin/PerformanceSettings';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
@@ -36,6 +37,7 @@ export default function AdminPage() {
{activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && (
<div className="space-y-4">
<FundamentalsCutoverSettings />
<ActivationSettings />
<ExitPolicySettings />
<PerformanceSettings />