news screen enhancement - 1
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Daily screening job — keeps the signal snapshot ledger (PRODUCT.md P0.1)
|
||||||
|
* accumulating even when nobody opens the UI.
|
||||||
|
*
|
||||||
|
* Universe = union of all users' watchlist tickers + all non-crypto holdings,
|
||||||
|
* or an explicit list passed on the command line.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run screen:daily # watchlist + holdings universe
|
||||||
|
* npm run screen:daily -- AAPL MSFT # explicit tickers
|
||||||
|
*
|
||||||
|
* Schedule for market close, e.g. crontab (4:30pm ET weekdays):
|
||||||
|
* 30 16 * * 1-5 cd /path/to/market_screener && npm run screen:daily
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import {
|
||||||
|
YahooFinanceClient,
|
||||||
|
BenchmarkProvider,
|
||||||
|
SignalSnapshotRepository,
|
||||||
|
createDb,
|
||||||
|
DatabaseConnection,
|
||||||
|
QueryAudit,
|
||||||
|
} from '../server/domains/shared';
|
||||||
|
import { QueryBuilder } from '../server/domains/shared/utils/QueryBuilder';
|
||||||
|
import { ScreenerEngine } from '../server/domains/screener';
|
||||||
|
import type { AssetResult } from '../server/domains/shared';
|
||||||
|
|
||||||
|
function universeFromDb(db: DatabaseConnection): string[] {
|
||||||
|
const watchlist = db
|
||||||
|
.all<{ ticker: string }>(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_WATCHLIST_TICKERS'))
|
||||||
|
.map((r) => r.ticker);
|
||||||
|
const holdings = db
|
||||||
|
.all<{ ticker: string }>(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_HOLDING_TICKERS'))
|
||||||
|
.map((r) => r.ticker);
|
||||||
|
return [...new Set([...watchlist, ...holdings])].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new DatabaseConnection(createDb(process.env.DB_PATH ?? './market-screener.db'), {
|
||||||
|
audit: new QueryAudit(),
|
||||||
|
logSlowQueries: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cliTickers = process.argv.slice(2).map((t) => t.toUpperCase());
|
||||||
|
const tickers = cliTickers.length > 0 ? cliTickers : universeFromDb(db);
|
||||||
|
|
||||||
|
if (tickers.length === 0) {
|
||||||
|
console.log('No tickers to screen — watchlist and holdings are empty.');
|
||||||
|
console.log('Pass tickers explicitly: npm run screen:daily -- AAPL MSFT');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Screening ${tickers.length} tickers: ${tickers.join(', ')}`);
|
||||||
|
|
||||||
|
const yahoo = new YahooFinanceClient();
|
||||||
|
const benchmark = new BenchmarkProvider(yahoo);
|
||||||
|
const engine = new ScreenerEngine(yahoo, benchmark);
|
||||||
|
const snapshots = new SignalSnapshotRepository(db);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await engine.screenWithProgress(tickers);
|
||||||
|
const rateRegime = results.marketContext?.rateRegime ?? null;
|
||||||
|
|
||||||
|
const assets = [...results.STOCK, ...results.ETF, ...results.BOND] as AssetResult[];
|
||||||
|
const written = snapshots.recordBatch(
|
||||||
|
assets.map((r) => ({
|
||||||
|
ticker: r.asset.ticker,
|
||||||
|
assetType: r.asset.type,
|
||||||
|
price: r.asset.currentPrice ?? null,
|
||||||
|
signal: r.signal,
|
||||||
|
fundamental: r.fundamental,
|
||||||
|
inflated: r.inflated,
|
||||||
|
rateRegime,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bySignal = new Map<string, number>();
|
||||||
|
for (const a of assets) bySignal.set(a.signal, (bySignal.get(a.signal) ?? 0) + 1);
|
||||||
|
|
||||||
|
console.log(`\nSnapshots written: ${written}`);
|
||||||
|
for (const [signal, count] of [...bySignal.entries()].sort()) {
|
||||||
|
console.log(` ${signal}: ${count}`);
|
||||||
|
}
|
||||||
|
if (results.ERROR.length > 0) {
|
||||||
|
console.log(`Errors (${results.ERROR.length}):`);
|
||||||
|
for (const e of results.ERROR) console.log(` ${e.ticker}: ${e.message}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Daily screen failed:', (err as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
|
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
|
||||||
"lint": "eslint . --ext .ts,.js",
|
"lint": "eslint . --ext .ts,.js",
|
||||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||||
|
"screen:daily": "tsx bin/daily-screen.ts",
|
||||||
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
||||||
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { ScreenerEngine } from './ScreenerEngine';
|
import { ScreenerEngine } from './ScreenerEngine';
|
||||||
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
|
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
|
||||||
import type { LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
||||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class ScreenerController {
|
export class ScreenerController {
|
||||||
@@ -65,11 +65,48 @@ export class ScreenerController {
|
|||||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||||
const results = await this.engine.screenTickers(tickers);
|
const results = await this.engine.screenTickers(tickers);
|
||||||
this.recordSnapshots(results, req);
|
this.recordSnapshots(results, req);
|
||||||
|
const dataHealth = ScreenerController.assessDataHealth(results);
|
||||||
|
if (dataHealth.degraded) {
|
||||||
|
req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...results,
|
...results,
|
||||||
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
||||||
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
|
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
|
||||||
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
|
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
|
||||||
|
dataHealth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P0.4 data-sanity sentinel — if a large share of screened stocks come back
|
||||||
|
* with null core fundamentals (P/E, ROE), the upstream source has likely
|
||||||
|
* changed schema or is throttling. Surface it loudly instead of letting
|
||||||
|
* everything silently degrade to "No Data" rows.
|
||||||
|
*/
|
||||||
|
private static assessDataHealth(results: ScreenerResult): DataHealth {
|
||||||
|
const THRESHOLD = 0.3; // >30% nulls = degraded
|
||||||
|
const MIN_SAMPLE = 3; // don't alarm on tiny batches
|
||||||
|
|
||||||
|
const stocks = results.STOCK as LiveAssetResult[];
|
||||||
|
const metrics = stocks.map(
|
||||||
|
(r) => r.asset.metrics as { peRatio?: number | null; returnOnEquity?: number | null },
|
||||||
|
);
|
||||||
|
const nullPeRatio = metrics.filter((m) => m.peRatio == null).length;
|
||||||
|
const nullRoe = metrics.filter((m) => m.returnOnEquity == null).length;
|
||||||
|
const total = metrics.length;
|
||||||
|
|
||||||
|
const degraded =
|
||||||
|
total >= MIN_SAMPLE && (nullPeRatio / total > THRESHOLD || nullRoe / total > THRESHOLD);
|
||||||
|
|
||||||
|
return {
|
||||||
|
degraded,
|
||||||
|
stocksChecked: total,
|
||||||
|
nullPeRatio,
|
||||||
|
nullRoe,
|
||||||
|
message: degraded
|
||||||
|
? `${Math.max(nullPeRatio, nullRoe)} of ${total} stocks returned no core fundamentals — data source may be degraded; treat this screen with caution`
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,20 @@ export const WATCHLIST_QUERIES = {
|
|||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Screening Universe Queries (bin/daily-screen.ts) ────────────────────────
|
||||||
|
|
||||||
|
export const UNIVERSE_QUERIES = {
|
||||||
|
// Every ticker pinned by any user
|
||||||
|
DISTINCT_WATCHLIST_TICKERS: 'SELECT DISTINCT ticker FROM watchlist ORDER BY ticker',
|
||||||
|
|
||||||
|
// Every ticker held by any user (crypto excluded — not fundamentally scored)
|
||||||
|
DISTINCT_HOLDING_TICKERS: `
|
||||||
|
SELECT DISTINCT ticker FROM holdings
|
||||||
|
WHERE type != 'crypto'
|
||||||
|
ORDER BY ticker
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
|
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
|
||||||
|
|
||||||
export const SIGNAL_SNAPSHOT_QUERIES = {
|
export const SIGNAL_SNAPSHOT_QUERIES = {
|
||||||
|
|||||||
@@ -87,10 +87,26 @@ export interface AssetResult {
|
|||||||
fundamental: ScoreResult;
|
fundamental: ScoreResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-source health for one screen batch (PRODUCT.md P0.4).
|
||||||
|
* Degraded = a large share of stocks came back without core fundamentals,
|
||||||
|
* which usually means the upstream data source changed or is throttling —
|
||||||
|
* not that the companies are actually missing data.
|
||||||
|
*/
|
||||||
|
export interface DataHealth {
|
||||||
|
degraded: boolean;
|
||||||
|
stocksChecked: number;
|
||||||
|
nullPeRatio: number;
|
||||||
|
nullRoe: number;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScreenerResult {
|
export interface ScreenerResult {
|
||||||
STOCK: AssetResult[];
|
STOCK: AssetResult[];
|
||||||
ETF: AssetResult[];
|
ETF: AssetResult[];
|
||||||
BOND: AssetResult[];
|
BOND: AssetResult[];
|
||||||
ERROR: Array<{ ticker: string; message: string }>;
|
ERROR: Array<{ ticker: string; message: string }>;
|
||||||
marketContext: import('./market.model.js').MarketContext;
|
marketContext: import('./market.model.js').MarketContext;
|
||||||
|
/** Set by the screener controller on API responses, not by the engine. */
|
||||||
|
dataHealth?: DataHealth;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type {
|
|||||||
ScoringRules,
|
ScoringRules,
|
||||||
ScoreAudit,
|
ScoreAudit,
|
||||||
ScoreResult,
|
ScoreResult,
|
||||||
|
VerdictTier,
|
||||||
|
DataHealth,
|
||||||
AssetResult,
|
AssetResult,
|
||||||
LiveAssetResult,
|
LiveAssetResult,
|
||||||
ScreenerResult,
|
ScreenerResult,
|
||||||
|
|||||||
@@ -85,12 +85,12 @@ test('BondScorer', async (t) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await t.test('handles null/undefined metrics gracefully', () => {
|
await t.test('handles null/undefined metrics gracefully', () => {
|
||||||
const metrics: BondMetrics = {
|
const metrics = {
|
||||||
ytm: null,
|
ytm: null,
|
||||||
duration: 5,
|
duration: 5,
|
||||||
creditRating: null,
|
creditRating: null,
|
||||||
creditRatingNumeric: null,
|
creditRatingNumeric: null,
|
||||||
};
|
} as unknown as BondMetrics;
|
||||||
|
|
||||||
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
||||||
// Should not crash
|
// Should not crash
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ class MockMarketCallRepository {
|
|||||||
quarter: 'Q2 2024',
|
quarter: 'Q2 2024',
|
||||||
thesis: 'Strong iPhone sales cycle',
|
thesis: 'Strong iPhone sales cycle',
|
||||||
tickers: ['AAPL'],
|
tickers: ['AAPL'],
|
||||||
date: new Date('2024-05-01'),
|
date: '2024-05-01',
|
||||||
snapshots: [{ ticker: 'AAPL', price: 180, date: new Date('2024-05-01') }],
|
snapshot: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async list(): Promise<(MarketCall & { id: string })[]> {
|
async list(): Promise<(MarketCall & { id: string })[]> {
|
||||||
return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime());
|
return this.calls.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<(MarketCall & { id: string }) | null> {
|
async get(id: string): Promise<(MarketCall & { id: string }) | null> {
|
||||||
@@ -27,7 +27,7 @@ class MockMarketCallRepository {
|
|||||||
|
|
||||||
async create(call: MarketCall): Promise<MarketCall & { id: string }> {
|
async create(call: MarketCall): Promise<MarketCall & { id: string }> {
|
||||||
const id = String(this.calls.length + 1);
|
const id = String(this.calls.length + 1);
|
||||||
const newCall = { id, ...call };
|
const newCall = { ...call, id };
|
||||||
this.calls.push(newCall);
|
this.calls.push(newCall);
|
||||||
return newCall;
|
return newCall;
|
||||||
}
|
}
|
||||||
@@ -152,7 +152,7 @@ test('CallsController', async (t) => {
|
|||||||
const calls = await repository.list();
|
const calls = await repository.list();
|
||||||
assert.ok(Array.isArray(calls));
|
assert.ok(Array.isArray(calls));
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.equal(calls[0].ticker || calls[0].title, 'AAPL Post-Earnings' || 'AAPL');
|
assert.equal(calls[0].title, 'AAPL Post-Earnings');
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('returns calls sorted by date (newest first)', async () => {
|
await t.test('returns calls sorted by date (newest first)', async () => {
|
||||||
@@ -164,8 +164,8 @@ test('CallsController', async (t) => {
|
|||||||
quarter: 'Q1 2024',
|
quarter: 'Q1 2024',
|
||||||
thesis: 'Old thesis',
|
thesis: 'Old thesis',
|
||||||
tickers: ['AAPL'],
|
tickers: ['AAPL'],
|
||||||
date: new Date('2024-01-01'),
|
date: '2024-01-01',
|
||||||
snapshots: [],
|
snapshot: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -173,13 +173,13 @@ test('CallsController', async (t) => {
|
|||||||
quarter: 'Q2 2024',
|
quarter: 'Q2 2024',
|
||||||
thesis: 'New thesis',
|
thesis: 'New thesis',
|
||||||
tickers: ['MSFT'],
|
tickers: ['MSFT'],
|
||||||
date: new Date('2024-05-01'),
|
date: '2024-05-01',
|
||||||
snapshots: [],
|
snapshot: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime());
|
return this.calls.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
@@ -205,14 +205,14 @@ test('CallsController', async (t) => {
|
|||||||
await t.test('creates new market call', async () => {
|
await t.test('creates new market call', async () => {
|
||||||
const repository = new MockMarketCallRepository() as any;
|
const repository = new MockMarketCallRepository() as any;
|
||||||
|
|
||||||
const newCall: MarketCall = {
|
const newCall = {
|
||||||
title: 'MSFT Q3 2024',
|
title: 'MSFT Q3 2024',
|
||||||
quarter: 'Q3 2024',
|
quarter: 'Q3 2024',
|
||||||
thesis: 'Cloud growth acceleration',
|
thesis: 'Cloud growth acceleration',
|
||||||
tickers: ['MSFT'],
|
tickers: ['MSFT'],
|
||||||
date: new Date('2024-07-01'),
|
date: '2024-07-01',
|
||||||
snapshots: [],
|
snapshot: {},
|
||||||
};
|
} as MarketCall;
|
||||||
|
|
||||||
const created = await repository.create(newCall);
|
const created = await repository.create(newCall);
|
||||||
assert.ok(created.id);
|
assert.ok(created.id);
|
||||||
@@ -261,14 +261,14 @@ test('CallsController', async (t) => {
|
|||||||
const repository = new MockMarketCallRepository() as any;
|
const repository = new MockMarketCallRepository() as any;
|
||||||
const engine = new MockScreenerEngine() as any;
|
const engine = new MockScreenerEngine() as any;
|
||||||
|
|
||||||
const newCall: MarketCall = {
|
const newCall = {
|
||||||
title: 'Tech Quartet',
|
title: 'Tech Quartet',
|
||||||
quarter: 'Q3 2024',
|
quarter: 'Q3 2024',
|
||||||
thesis: 'All tech leaders',
|
thesis: 'All tech leaders',
|
||||||
tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'],
|
tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'],
|
||||||
date: new Date('2024-07-01'),
|
date: '2024-07-01',
|
||||||
snapshots: [],
|
snapshot: {},
|
||||||
};
|
} as MarketCall;
|
||||||
|
|
||||||
const created = await repository.create(newCall);
|
const created = await repository.create(newCall);
|
||||||
const results = await engine.screenTickers(created.tickers);
|
const results = await engine.screenTickers(created.tickers);
|
||||||
@@ -290,11 +290,13 @@ test('CallsController', async (t) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('call includes snapshots of entry prices', async () => {
|
await t.test('call includes a snapshot of entry prices', async () => {
|
||||||
const repository = new MockMarketCallRepository() as any;
|
const repository = new MockMarketCallRepository() as any;
|
||||||
|
|
||||||
const call = await repository.get('1');
|
const call = await repository.get('1');
|
||||||
assert.ok(call);
|
assert.ok(call);
|
||||||
assert.ok(Array.isArray(call.snapshots));
|
// MarketCall.snapshot is Record<ticker, TickerSnapshot>, not an array
|
||||||
|
assert.equal(typeof call.snapshot, 'object');
|
||||||
|
assert.ok(!Array.isArray(call.snapshot));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ test('PortfolioAdvisor', async (t) => {
|
|||||||
displayMetrics: {},
|
displayMetrics: {},
|
||||||
} as any,
|
} as any,
|
||||||
{
|
{
|
||||||
signal: SIGNAL.BUY,
|
signal: SIGNAL.STRONG_BUY,
|
||||||
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
||||||
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
||||||
asset: {
|
asset: {
|
||||||
@@ -239,7 +239,7 @@ test('PortfolioAdvisor', async (t) => {
|
|||||||
displayMetrics: {},
|
displayMetrics: {},
|
||||||
} as any,
|
} as any,
|
||||||
{
|
{
|
||||||
signal: SIGNAL.BUY,
|
signal: SIGNAL.STRONG_BUY,
|
||||||
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
||||||
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
|
||||||
asset: {
|
asset: {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { ScreenerController } from '../server/domains/screener/screener.controller.js';
|
import { ScreenerController } from '../server/domains/screener/screener.controller.js';
|
||||||
import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js';
|
import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js';
|
||||||
|
|
||||||
import type {
|
import type { LiveAssetResult, MarketContext } from '../server/domains/shared/types/index.js';
|
||||||
LiveAssetResult,
|
|
||||||
MarketContext,
|
|
||||||
Stock,
|
|
||||||
} from '../server/domains/shared/types/index.js';
|
|
||||||
import { ASSET_TYPE, SIGNAL } from '../server/domains/shared/config/constants.js';
|
import { ASSET_TYPE, SIGNAL } from '../server/domains/shared/config/constants.js';
|
||||||
|
|
||||||
// Mock implementations
|
// Mock implementations
|
||||||
@@ -43,12 +39,24 @@ class MockScreenerEngine extends ScreenerEngine {
|
|||||||
returnOnEquity: 95.2,
|
returnOnEquity: 95.2,
|
||||||
freeCashFlow: 100000000,
|
freeCashFlow: 100000000,
|
||||||
}),
|
}),
|
||||||
} as unknown as Stock;
|
} as unknown as LiveAssetResult['asset'];
|
||||||
|
|
||||||
const mockResult: LiveAssetResult = {
|
const mockResult: LiveAssetResult = {
|
||||||
asset: mockStock,
|
asset: mockStock,
|
||||||
fundamentalScore: { label: '✓ BUY', scoreSummary: 'Quality gate PASS' },
|
fundamental: {
|
||||||
inflatedScore: { label: '✓ BUY', scoreSummary: 'Market adjusted gate PASS' },
|
label: '🟢 BUY (High Conviction)',
|
||||||
|
tier: 'PASS',
|
||||||
|
score: 9,
|
||||||
|
scoreSummary: 'Quality gate PASS',
|
||||||
|
audit: { passedGates: true },
|
||||||
|
},
|
||||||
|
inflated: {
|
||||||
|
label: '🟢 BUY (High Conviction)',
|
||||||
|
tier: 'PASS',
|
||||||
|
score: 9,
|
||||||
|
scoreSummary: 'Market adjusted gate PASS',
|
||||||
|
audit: { passedGates: true },
|
||||||
|
},
|
||||||
signal: SIGNAL.STRONG_BUY,
|
signal: SIGNAL.STRONG_BUY,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,7 +198,7 @@ test('ScreenerController', async (t) => {
|
|||||||
assert.equal(results.STOCK.length, 1);
|
assert.equal(results.STOCK.length, 1);
|
||||||
const result = results.STOCK[0];
|
const result = results.STOCK[0];
|
||||||
assert.ok(result.signal);
|
assert.ok(result.signal);
|
||||||
assert.ok(result.fundamentalScore);
|
assert.ok(result.fundamental);
|
||||||
assert.ok(result.inflatedScore);
|
assert.ok(result.inflated);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ class ScreenerStore {
|
|||||||
// ── Derived ────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────
|
||||||
ctx = $derived(this.results?.marketContext ?? null);
|
ctx = $derived(this.results?.marketContext ?? null);
|
||||||
|
|
||||||
|
/** P0.4 data-sanity sentinel — dismissible per screen run. */
|
||||||
|
healthDismissed = $state(false);
|
||||||
|
dataHealth = $derived(
|
||||||
|
!this.healthDismissed && this.results?.dataHealth?.degraded ? this.results.dataHealth : null,
|
||||||
|
);
|
||||||
|
|
||||||
allAssets = $derived(
|
allAssets = $derived(
|
||||||
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
|
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
|
||||||
);
|
);
|
||||||
@@ -28,6 +34,7 @@ class ScreenerStore {
|
|||||||
// ── Actions ────────────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────────────
|
||||||
async screen(): Promise<void> {
|
async screen(): Promise<void> {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.healthDismissed = false;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const tickers = this.input
|
const tickers = this.input
|
||||||
@@ -46,6 +53,7 @@ class ScreenerStore {
|
|||||||
async reloadCatalysts(): Promise<void> {
|
async reloadCatalysts(): Promise<void> {
|
||||||
this.loadingCats = true;
|
this.loadingCats = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.healthDismissed = false;
|
||||||
try {
|
try {
|
||||||
const cat = await fetchCatalysts();
|
const cat = await fetchCatalysts();
|
||||||
this.input = cat.tickers.join(', ');
|
this.input = cat.tickers.join(', ');
|
||||||
|
|||||||
@@ -62,6 +62,13 @@
|
|||||||
<div class="error-banner">⚠ {s.error}</div>
|
<div class="error-banner">⚠ {s.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if s.dataHealth}
|
||||||
|
<div class="warn-banner" role="alert">
|
||||||
|
<span>⚠ {s.dataHealth.message}</span>
|
||||||
|
<button class="warn-dismiss" onclick={() => s.healthDismissed = true} title="Dismiss">✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if s.loading || s.loadingCats}
|
{#if s.loading || s.loadingCats}
|
||||||
<div class="loading-area">
|
<div class="loading-area">
|
||||||
<Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
|
<Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
|
||||||
|
|||||||
@@ -76,3 +76,31 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
font-size: var(--fs-md);
|
font-size: var(--fs-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Warning banner (data-sanity sentinel, P0.4) ───────────────────────────
|
||||||
|
|
||||||
|
.warn-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--amber-dim);
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--amber);
|
||||||
|
padding: 10px var(--space-lg);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: var(--fs-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover { filter: brightness(1.3); }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user