Add Admin control for fundamentals cutover
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user