phase-6: typescript introduction
This commit is contained in:
@@ -1,5 +1,29 @@
|
||||
import type { BondMetrics } from '../assets/Bond.js';
|
||||
import type { MarketContext } from '../../types.js';
|
||||
|
||||
interface SanitizedBondMetrics {
|
||||
ytm: number;
|
||||
duration: number;
|
||||
creditRating: string;
|
||||
creditRatingNumeric: number;
|
||||
}
|
||||
|
||||
interface ScoreOutput {
|
||||
label: string;
|
||||
scoreSummary: string;
|
||||
audit: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const BondScorer = {
|
||||
score(m, rules, context) {
|
||||
score(
|
||||
m: BondMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
context?: MarketContext | null,
|
||||
): ScoreOutput {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||
@@ -12,10 +36,9 @@ export const BondScorer = {
|
||||
};
|
||||
}
|
||||
|
||||
// Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%)
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown = {
|
||||
const breakdown: Record<string, number> = {
|
||||
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
|
||||
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
|
||||
};
|
||||
@@ -28,11 +51,12 @@ export const BondScorer = {
|
||||
};
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0;
|
||||
_sanitize(m: BondMetrics): SanitizedBondMetrics {
|
||||
const pct = (v: unknown): number =>
|
||||
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(m.duration) || 0,
|
||||
duration: parseFloat(String(m.duration)) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
@@ -1,22 +1,36 @@
|
||||
import type { EtfMetrics } from '../assets/Etf.js';
|
||||
|
||||
interface ScoreOutput {
|
||||
label: string;
|
||||
scoreSummary: string;
|
||||
audit?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const EtfScorer = {
|
||||
score(m, rules) {
|
||||
score(
|
||||
m: EtfMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreOutput {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||
yield: parseFloat(m.yield) || 0,
|
||||
volume: parseFloat(m.volume) || 0,
|
||||
fiveYearReturn: parseFloat(m.fiveYearReturn) || 0,
|
||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
||||
yield: parseFloat(String(m.yield)) || 0,
|
||||
volume: parseFloat(String(m.volume)) || 0,
|
||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
|
||||
}
|
||||
|
||||
const breakdown = {
|
||||
const breakdown: Record<string, number> = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2,
|
||||
// 5Y return: strong long-term performance vs the ~10% S&P average is rewarded
|
||||
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
|
||||
fiveYearReturn:
|
||||
thresholds.minFiveYearReturn != null
|
||||
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
|
||||
@@ -1,15 +1,51 @@
|
||||
import { SIGNAL } from '../../config/constants.js';
|
||||
import type { StockMetrics } from '../assets/Stock.js';
|
||||
|
||||
const n = (v) => {
|
||||
const f = parseFloat(v);
|
||||
type NumVal = number | null;
|
||||
|
||||
const n = (v: unknown): NumVal => {
|
||||
const f = parseFloat(String(v));
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
};
|
||||
|
||||
const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1);
|
||||
const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1);
|
||||
const scoreValue = (val: number, high: number, med: number, weight: number): number =>
|
||||
val >= high ? weight : val >= med ? 1 : -1;
|
||||
|
||||
const scorePeg = (val: number, high: number, med: number, weight: number): number =>
|
||||
val <= high ? weight : val <= med ? 1 : -1;
|
||||
|
||||
interface SanitizedMetrics {
|
||||
debtToEquity: NumVal;
|
||||
quickRatio: NumVal;
|
||||
peRatio: NumVal;
|
||||
pegRatio: NumVal;
|
||||
priceToBook: NumVal;
|
||||
netProfitMargin: NumVal;
|
||||
operatingMargin: NumVal;
|
||||
returnOnEquity: NumVal;
|
||||
revenueGrowth: NumVal;
|
||||
fcfYield: NumVal;
|
||||
dividendYield: NumVal;
|
||||
pFFO: NumVal;
|
||||
beta: NumVal;
|
||||
week52Position: NumVal;
|
||||
}
|
||||
|
||||
interface ScoreOutput {
|
||||
label: string;
|
||||
scoreSummary: string;
|
||||
audit: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const StockScorer = {
|
||||
score(metrics, rules) {
|
||||
score(
|
||||
metrics: StockMetrics,
|
||||
rules: {
|
||||
gates: Record<string, number>;
|
||||
weights: Record<string, number>;
|
||||
thresholds: Record<string, number>;
|
||||
},
|
||||
): ScoreOutput {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const m = this._sanitize(metrics);
|
||||
|
||||
@@ -30,7 +66,7 @@ export const StockScorer = {
|
||||
gates.maxPriceToBook &&
|
||||
m.priceToBook > gates.maxPriceToBook &&
|
||||
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
|
||||
].filter(Boolean);
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
@@ -44,14 +80,14 @@ export const StockScorer = {
|
||||
{
|
||||
key: 'roe',
|
||||
active: weights.roe > 0 && m.returnOnEquity != null,
|
||||
fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe),
|
||||
fn: () => scoreValue(m.returnOnEquity!, thresholds.roeHigh, thresholds.roeMed, weights.roe),
|
||||
},
|
||||
{
|
||||
key: 'opMargin',
|
||||
active: weights.opMargin > 0 && m.operatingMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.operatingMargin,
|
||||
m.operatingMargin!,
|
||||
thresholds.opMarginHigh,
|
||||
thresholds.opMarginMed,
|
||||
weights.opMargin,
|
||||
@@ -62,7 +98,7 @@ export const StockScorer = {
|
||||
active: weights.margin > 0 && m.netProfitMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.netProfitMargin,
|
||||
m.netProfitMargin!,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
@@ -71,41 +107,41 @@ export const StockScorer = {
|
||||
{
|
||||
key: 'peg',
|
||||
active: weights.peg > 0 && m.pegRatio != null,
|
||||
fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
fn: () => scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
active: weights.revenue > 0 && m.revenueGrowth != null,
|
||||
fn: () =>
|
||||
scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue),
|
||||
scoreValue(m.revenueGrowth!, thresholds.revHigh, thresholds.revMed, weights.revenue),
|
||||
},
|
||||
{
|
||||
key: 'fcf',
|
||||
active: weights.fcf > 0 && m.fcfYield != null,
|
||||
fn: () =>
|
||||
scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
|
||||
scoreValue(m.fcfYield!, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
|
||||
fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1),
|
||||
fn: () => (m.dividendYield! >= (thresholds.minYield ?? 4) ? weights.yield : -1),
|
||||
},
|
||||
{
|
||||
key: 'pFFO',
|
||||
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
|
||||
fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
|
||||
fn: () => (m.pFFO! <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
|
||||
},
|
||||
{
|
||||
key: 'priceToBook',
|
||||
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
|
||||
fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
fn: () => scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
},
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const breakdown: Record<string, number> = {};
|
||||
const totalScore = factors.reduce((sum, f) => {
|
||||
if (!f.active) return sum;
|
||||
breakdown[f.key] = f.fn();
|
||||
breakdown[f.key] = f.fn() as number;
|
||||
return sum + breakdown[f.key];
|
||||
}, 0);
|
||||
|
||||
@@ -116,7 +152,7 @@ export const StockScorer = {
|
||||
m.week52Position != null &&
|
||||
m.week52Position < 0.1 &&
|
||||
'Near 52-week low — potential opportunity',
|
||||
].filter(Boolean);
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return {
|
||||
label: this._label(totalScore),
|
||||
@@ -125,16 +161,16 @@ export const StockScorer = {
|
||||
};
|
||||
},
|
||||
|
||||
_label(score) {
|
||||
_label(score: number): string {
|
||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||
if (score >= 0) return '🟡 HOLD';
|
||||
return '🔴 REJECT';
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
_sanitize(m: StockMetrics): SanitizedMetrics {
|
||||
const w52 =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||
: null;
|
||||
return {
|
||||
Reference in New Issue
Block a user