From d45229326714c7477f27d5802d9a777e890f5829 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Sat, 27 Jun 2026 12:50:58 +0200 Subject: [PATCH 1/3] feat(trading-strategies): add momentum-rotation strategy A live strategy that holds the top picks from momentum + scorecard and rebalances when momentum changes (monthly, since the 12-1 window is a monthly snapshot). - computeRebalance: pure, equal-weight, low-churn rebalance. Liquidate names that left the target set, buy new entrants at an equal-weight slice (equity / count), and leave unchanged holdings alone, so an unchanged set trades nothing. - MomentumRotation: orchestrator that ranks the S&P 500 by 12-1 momentum, scores the top winners, keeps the best 5, reads Alpaca positions/equity, and either previews the trades (plan(), read-only) or executes them (rebalance()). - Extract the momentum ranking (price fetch + rank) into a shared util so the report and the rotation share one implementation. The scorecard uses current analyst data, so this runs forward only (live/paper), not as a backtest. Verified end to end with a read-only plan() against a live account. --- packages/trading-strategies/src/index.ts | 1 + .../SP500MomentumReport.ts | 81 +--------------- .../src/rotation/MomentumRotation.ts | 93 +++++++++++++++++++ .../src/rotation/computeRebalance.test.ts | 79 ++++++++++++++++ .../src/rotation/computeRebalance.ts | 64 +++++++++++++ .../trading-strategies/src/rotation/index.ts | 2 + .../src/util/momentumRanking.ts | 77 +++++++++++++++ 7 files changed, 321 insertions(+), 76 deletions(-) create mode 100644 packages/trading-strategies/src/rotation/MomentumRotation.ts create mode 100644 packages/trading-strategies/src/rotation/computeRebalance.test.ts create mode 100644 packages/trading-strategies/src/rotation/computeRebalance.ts create mode 100644 packages/trading-strategies/src/rotation/index.ts create mode 100644 packages/trading-strategies/src/util/momentumRanking.ts diff --git a/packages/trading-strategies/src/index.ts b/packages/trading-strategies/src/index.ts index dbb2e3840..1b4a575aa 100644 --- a/packages/trading-strategies/src/index.ts +++ b/packages/trading-strategies/src/index.ts @@ -1,4 +1,5 @@ export * from './backtest/index.js'; +export * from './rotation/index.js'; export * from './scorecard/index.js'; export * from './strategy/index.js'; export {BuyOnceStrategy, BuyOnceSchema, type BuyOnceConfig} from './strategy-buy-once/BuyOnceStrategy.js'; diff --git a/packages/trading-strategies/src/report-sp500-momentum/SP500MomentumReport.ts b/packages/trading-strategies/src/report-sp500-momentum/SP500MomentumReport.ts index 9b2c0a9ca..ca16b12ca 100644 --- a/packages/trading-strategies/src/report-sp500-momentum/SP500MomentumReport.ts +++ b/packages/trading-strategies/src/report-sp500-momentum/SP500MomentumReport.ts @@ -2,6 +2,7 @@ import {z} from 'zod'; import type {AlpacaAPI} from '@typedtrader/exchange'; import {MESSAGE_BREAK, Report} from '../report/Report.js'; import {fetchUsEquityNames, formatSymbolWithName, TELEGRAM_TABLE_NAME_MAX} from '../util/formatSymbolWithName.js'; +import {getMomentumRanking, type MomentumResult} from '../util/momentumRanking.js'; import {getDateString} from '../util/TimeUtil.js'; import {SP500_TICKERS, SP500_TIMEZONE} from '../util/sp500Tickers.js'; @@ -9,15 +10,6 @@ export const SP500MomentumSchema = z.object({}); export type SP500MomentumConfig = z.infer; -interface MomentumResult { - ticker: string; - priceNow: number; - price12MonthsAgo: number; - returnPct: number; -} - -const BATCH_SIZE = 1000; - const WEEKEND_SHIFT_TO_MONDAY: Record = { 0: 1, // Sunday → Monday 6: 2, // Saturday → Monday @@ -76,25 +68,6 @@ interface RankedMomentum extends MomentumResult { rankDelta: number | null; } -/** Ranks every ticker with a usable price pair by its window return, best first. */ -function rankByMomentum(endPrices: Map, startPrices: Map): MomentumResult[] { - const results: MomentumResult[] = []; - for (const ticker of SP500_TICKERS) { - const priceNow = endPrices.get(ticker); - const priceThen = startPrices.get(ticker); - if (priceNow != null && priceThen != null && priceThen > 0) { - results.push({ - price12MonthsAgo: priceThen, - priceNow, - returnPct: ((priceNow - priceThen) / priceThen) * 100, - ticker, - }); - } - } - results.sort((a, b) => b.returnPct - a.returnPct); - return results; -} - /** Annotates the current ranking with each ticker's rank movement against the previous month's ranking. */ export function withRankDeltas(current: MomentumResult[], previous: MomentumResult[]): RankedMomentum[] { const previousRank = new Map(); @@ -143,61 +116,17 @@ export class SP500MomentumReport extends Report { const toDate = getDateString(current.recentDate); const fromDate = getDateString(current.pastDate); - const [currentEnd, currentStart, previousEnd, previousStart, names] = await Promise.all([ - this.#getClosingPrices(SP500_TICKERS, current.recentDate), - this.#getClosingPrices(SP500_TICKERS, current.pastDate), - this.#getClosingPrices(SP500_TICKERS, previous.recentDate), - this.#getClosingPrices(SP500_TICKERS, previous.pastDate), + const [currentRanking, previousRanking, names] = await Promise.all([ + getMomentumRanking(this.#api, current), + getMomentumRanking(this.#api, previous), fetchUsEquityNames(this.#api), ]); - const ranked = withRankDeltas(rankByMomentum(currentEnd, currentStart), rankByMomentum(previousEnd, previousStart)); + const ranked = withRankDeltas(currentRanking, previousRanking); return this.#formatResults(ranked, fromDate, toDate, names); } - async #getClosingPrices(symbols: string[], targetDate: Date): Promise> { - const prices = new Map(); - - // Fetch a 5-day window around the target date to account for holidays - const start = new Date(targetDate); - const end = new Date(targetDate); - end.setDate(end.getDate() + 5); - - for (let i = 0; i < symbols.length; i += BATCH_SIZE) { - const batch = symbols.slice(i, i + BATCH_SIZE); - const batchPrices = await this.#fetchClosingPricesForBatch(batch, start, end); - - for (const [symbol, price] of batchPrices) { - prices.set(symbol, price); - } - } - - return prices; - } - - async #fetchClosingPricesForBatch(symbols: string[], start: Date, end: Date): Promise> { - const result = new Map(); - - const response = await this.#api.getStockBars({ - end: end.toISOString(), - feed: 'iex', - limit: 10_000, - start: start.toISOString(), - symbols: symbols.join(','), - timeframe: '1Day', - }); - - for (const [symbol, symbolBars] of Object.entries(response.bars)) { - if (symbolBars.length > 0) { - // Use the first available bar's close as the price near the target date - result.set(symbol, symbolBars[0].c); - } - } - - return result; - } - #formatResults(results: RankedMomentum[], fromDate: string, toDate: string, names: Map) { const top = 20; const lines: string[] = []; diff --git a/packages/trading-strategies/src/rotation/MomentumRotation.ts b/packages/trading-strategies/src/rotation/MomentumRotation.ts new file mode 100644 index 000000000..067b62014 --- /dev/null +++ b/packages/trading-strategies/src/rotation/MomentumRotation.ts @@ -0,0 +1,93 @@ +import type {AlpacaAPI, FmpAPI} from '@typedtrader/exchange'; +import {computeRebalance, type Holding, type RebalancePlan} from './computeRebalance.js'; +import {getExchangeYearMonth, getMomentumWindow} from '../report-sp500-momentum/SP500MomentumReport.js'; +import {MomentumScorecard} from '../scorecard/MomentumScorecard.js'; +import {getMomentumRanking} from '../util/momentumRanking.js'; +import {SP500_TIMEZONE} from '../util/sp500Tickers.js'; + +export interface MomentumRotationConfig { + /** Number of equal-weight positions to hold. */ + positionCount: number; + /** Number of top momentum winners fed into the scorecard before picking the best `positionCount`. */ + momentumPoolSize: number; +} + +const DEFAULT_CONFIG: MomentumRotationConfig = {momentumPoolSize: 20, positionCount: 5}; + +/** + * Live momentum-rotation strategy. + * + * Each run it rebuilds the target portfolio (rank the S&P 500 by 12-1 momentum, score the top + * winners, keep the best `positionCount`) and trades the minimum needed to match it: liquidate names + * that dropped out, buy new entrants at an equal-weight slice, and leave everything else alone. + * + * Because the 12-1 momentum window is a monthly snapshot, the natural cadence is monthly — that is + * when "momentum changes". The scorecard uses *current* analyst data, so this only makes sense run + * forward (live or paper); it cannot be backtested faithfully. + * + * Use {@link plan} to preview the trades (read-only) and {@link rebalance} to actually place them. + */ +export class MomentumRotation { + readonly #alpaca: AlpacaAPI; + readonly #scorecard: MomentumScorecard; + readonly #config: MomentumRotationConfig; + + constructor(alpaca: AlpacaAPI, fmp: FmpAPI, config: Partial = {}) { + this.#alpaca = alpaca; + this.#scorecard = new MomentumScorecard(fmp); + this.#config = {...DEFAULT_CONFIG, ...config}; + } + + /** The target portfolio: the top momentum winners, scored, reduced to the best `positionCount`. */ + async selectTargets(now: Date): Promise { + const {month, year} = getExchangeYearMonth(now.toISOString(), SP500_TIMEZONE); + const ranking = await getMomentumRanking(this.#alpaca, getMomentumWindow(year, month)); + const winners = ranking.slice(0, this.#config.momentumPoolSize).map(result => result.ticker); + const scored = await this.#scorecard.build(winners, now); + return scored.slice(0, this.#config.positionCount).map(row => row.ticker); + } + + /** Builds the rebalance plan without placing any orders, so it can be reviewed or paper-traded first. */ + async plan(now: Date): Promise<{plan: RebalancePlan; targets: string[]}> { + const {plan, targets} = await this.#buildPlan(now); + return {plan, targets}; + } + + /** Builds the plan and executes it: liquidate dropouts first to free cash, then buy the new entrants. */ + async rebalance(now: Date): Promise<{plan: RebalancePlan; targets: string[]}> { + const {plan, positions, targets} = await this.#buildPlan(now); + const quantityByTicker = new Map(positions.map(position => [position.symbol, position.qty])); + + for (const sell of plan.sells) { + await this.#alpaca.postOrder({ + qty: quantityByTicker.get(sell.ticker), + side: 'sell', + symbol: sell.ticker, + time_in_force: 'day', + type: 'market', + }); + } + for (const buy of plan.buys) { + await this.#alpaca.postOrder({ + notional: buy.notionalUsd.toFixed(2), + side: 'buy', + symbol: buy.ticker, + time_in_force: 'day', + type: 'market', + }); + } + + return {plan, targets}; + } + + async #buildPlan(now: Date) { + const targets = await this.selectTargets(now); + const [positions, account] = await Promise.all([this.#alpaca.getPositions(), this.#alpaca.getAccount()]); + const holdings: Holding[] = positions.map(position => ({ + marketValueUsd: Number(position.market_value), + ticker: position.symbol, + })); + const plan = computeRebalance(holdings, targets, Number(account.equity), this.#config.positionCount); + return {plan, positions, targets}; + } +} diff --git a/packages/trading-strategies/src/rotation/computeRebalance.test.ts b/packages/trading-strategies/src/rotation/computeRebalance.test.ts new file mode 100644 index 000000000..872f7aab0 --- /dev/null +++ b/packages/trading-strategies/src/rotation/computeRebalance.test.ts @@ -0,0 +1,79 @@ +import {describe, expect, it} from 'vitest'; +import {computeRebalance} from './computeRebalance.js'; + +const COUNT = 5; + +describe('computeRebalance', () => { + it('buys the full set on a fresh (empty) portfolio, equal-weight', () => { + const plan = computeRebalance([], ['MU', 'GOOG', 'GOOGL', 'AMCR', 'KEYS'], 100_000, COUNT); + + expect(plan.sells, 'nothing to sell').toEqual([]); + expect(plan.holds, 'nothing held yet').toEqual([]); + expect(plan.buys.map(b => b.ticker)).toEqual(['MU', 'GOOG', 'GOOGL', 'AMCR', 'KEYS']); + expect( + plan.buys.every(b => b.notionalUsd === 20_000), + 'each gets equity / 5' + ).toBe(true); + }); + + it('produces an empty plan when the target set is unchanged (low churn)', () => { + const holdings = [ + {marketValueUsd: 25_000, ticker: 'MU'}, + {marketValueUsd: 18_000, ticker: 'GOOG'}, + {marketValueUsd: 21_000, ticker: 'GOOGL'}, + {marketValueUsd: 19_000, ticker: 'AMCR'}, + {marketValueUsd: 17_000, ticker: 'KEYS'}, + ]; + const plan = computeRebalance(holdings, ['MU', 'GOOG', 'GOOGL', 'AMCR', 'KEYS'], 100_000, COUNT); + + expect(plan.sells, 'no trades').toEqual([]); + expect(plan.buys, 'no trades').toEqual([]); + expect(plan.holds).toHaveLength(5); + }); + + it('swaps only the names that changed, leaving the rest untouched', () => { + const holdings = [ + {marketValueUsd: 30_000, ticker: 'MU'}, // drifted up, stays in set — not re-weighted + {marketValueUsd: 18_000, ticker: 'GOOG'}, + {marketValueUsd: 21_000, ticker: 'GOOGL'}, + {marketValueUsd: 19_000, ticker: 'AMCR'}, + {marketValueUsd: 12_000, ticker: 'ON'}, // dropped out of the new top 5 + ]; + // ON replaced by AMD; the other four are unchanged. + const plan = computeRebalance(holdings, ['MU', 'GOOG', 'GOOGL', 'AMCR', 'AMD'], 100_000, COUNT); + + expect(plan.sells, 'liquidate the dropout at its full value').toEqual([ + {notionalUsd: 12_000, side: 'SELL', ticker: 'ON'}, + ]); + expect(plan.buys, 'buy the entrant at equal weight').toEqual([{notionalUsd: 20_000, side: 'BUY', ticker: 'AMD'}]); + expect(plan.holds.sort(), 'the unchanged four are held, no re-weight on MU').toEqual([ + 'AMCR', + 'GOOG', + 'GOOGL', + 'MU', + ]); + }); + + it('liquidates everything when the target set is empty', () => { + const holdings = [ + {marketValueUsd: 25_000, ticker: 'MU'}, + {marketValueUsd: 18_000, ticker: 'GOOG'}, + ]; + const plan = computeRebalance(holdings, [], 100_000, COUNT); + + expect(plan.sells.map(s => s.ticker).sort()).toEqual(['GOOG', 'MU']); + expect(plan.buys).toEqual([]); + expect(plan.holds).toEqual([]); + }); + + it('leaves cash when fewer than positionCount names qualify', () => { + const plan = computeRebalance([], ['MU', 'GOOG'], 100_000, COUNT); + + expect( + plan.buys.every(b => b.notionalUsd === 20_000), + 'still equity / 5, not equity / 2' + ).toBe(true); + // 2 x 20k invested, 60k left in cash by design. + expect(plan.buys.reduce((sum, b) => sum + b.notionalUsd, 0)).toBe(40_000); + }); +}); diff --git a/packages/trading-strategies/src/rotation/computeRebalance.ts b/packages/trading-strategies/src/rotation/computeRebalance.ts new file mode 100644 index 000000000..80211fd95 --- /dev/null +++ b/packages/trading-strategies/src/rotation/computeRebalance.ts @@ -0,0 +1,64 @@ +/** + * Deterministic, equal-weight, low-churn rebalance for the momentum-rotation strategy. + * + * Given the current holdings and a fresh target set (the top picks from momentum + scorecard), it + * works out the minimum set of trades: + * + * - **Sell** any holding that has dropped out of the target set (liquidate it fully). + * - **Buy** any target not yet held, sized to an equal-weight slice of total equity. + * - **Hold** anything that is in both, with no trade at all. + * + * "Low churn" is the point: positions that stay in the set are left exactly as they are (no + * re-weighting drift), so an unchanged target set produces an empty plan and costs nothing in fees. + * The maths is pure and independent of any broker, so it is fully unit-testable. + */ + +export interface Holding { + ticker: string; + /** Current market value of the position, in account currency. */ + marketValueUsd: number; +} + +export interface RebalanceOrder { + ticker: string; + side: 'BUY' | 'SELL'; + /** Dollar amount to trade (a notional / fractional order). Sells liquidate the full position. */ + notionalUsd: number; +} + +export interface RebalancePlan { + sells: RebalanceOrder[]; + buys: RebalanceOrder[]; + /** Tickers already held that remain in the target set — deliberately left untouched. */ + holds: string[]; +} + +/** + * @param holdings Current positions and their market values. + * @param targets The desired tickers (e.g. the top 5 from the scorecard). + * @param totalEquityUsd Total account equity (cash + positions), used to size new buys. + * @param positionCount Equal-weight divisor (e.g. 5). Dividing by the configured count rather than + * the number of targets means an under-filled set leaves the remainder in cash. + */ +export function computeRebalance( + holdings: Holding[], + targets: string[], + totalEquityUsd: number, + positionCount: number +): RebalancePlan { + const targetSet = new Set(targets); + const heldSet = new Set(holdings.map(holding => holding.ticker)); + const perPositionUsd = totalEquityUsd / positionCount; + + const sells: RebalanceOrder[] = holdings + .filter(holding => !targetSet.has(holding.ticker)) + .map(holding => ({notionalUsd: holding.marketValueUsd, side: 'SELL', ticker: holding.ticker})); + + const buys: RebalanceOrder[] = targets + .filter(ticker => !heldSet.has(ticker)) + .map(ticker => ({notionalUsd: perPositionUsd, side: 'BUY', ticker})); + + const holds = targets.filter(ticker => heldSet.has(ticker)); + + return {buys, holds, sells}; +} diff --git a/packages/trading-strategies/src/rotation/index.ts b/packages/trading-strategies/src/rotation/index.ts new file mode 100644 index 000000000..175ff86bb --- /dev/null +++ b/packages/trading-strategies/src/rotation/index.ts @@ -0,0 +1,2 @@ +export * from './computeRebalance.js'; +export * from './MomentumRotation.js'; diff --git a/packages/trading-strategies/src/util/momentumRanking.ts b/packages/trading-strategies/src/util/momentumRanking.ts new file mode 100644 index 000000000..29cfc41de --- /dev/null +++ b/packages/trading-strategies/src/util/momentumRanking.ts @@ -0,0 +1,77 @@ +import type {AlpacaAPI} from '@typedtrader/exchange'; +import {SP500_TICKERS} from './sp500Tickers.js'; + +const BATCH_SIZE = 1000; + +export interface MomentumResult { + ticker: string; + priceNow: number; + price12MonthsAgo: number; + returnPct: number; +} + +/** + * Closing price near a target date for each symbol. Fetches a 5-day window so a holiday on the exact + * anchor date doesn't leave a hole, and takes the first available bar at or after the date. + */ +export async function fetchClosingPrices( + api: AlpacaAPI, + symbols: string[], + targetDate: Date +): Promise> { + const prices = new Map(); + + const start = new Date(targetDate); + const end = new Date(targetDate); + end.setDate(end.getDate() + 5); + + for (let i = 0; i < symbols.length; i += BATCH_SIZE) { + const batch = symbols.slice(i, i + BATCH_SIZE); + const response = await api.getStockBars({ + end: end.toISOString(), + feed: 'iex', + limit: 10_000, + start: start.toISOString(), + symbols: batch.join(','), + timeframe: '1Day', + }); + for (const [symbol, symbolBars] of Object.entries(response.bars)) { + if (symbolBars.length > 0) { + prices.set(symbol, symbolBars[0].c); + } + } + } + + return prices; +} + +/** Ranks every ticker with a usable price pair by its window return, best first. */ +export function rankByMomentum(endPrices: Map, startPrices: Map): MomentumResult[] { + const results: MomentumResult[] = []; + for (const ticker of SP500_TICKERS) { + const priceNow = endPrices.get(ticker); + const priceThen = startPrices.get(ticker); + if (priceNow != null && priceThen != null && priceThen > 0) { + results.push({ + price12MonthsAgo: priceThen, + priceNow, + returnPct: ((priceNow - priceThen) / priceThen) * 100, + ticker, + }); + } + } + results.sort((a, b) => b.returnPct - a.returnPct); + return results; +} + +/** Fetches both anchors of a momentum window and returns the S&P 500 ranking, best first. */ +export async function getMomentumRanking( + api: AlpacaAPI, + window: {pastDate: Date; recentDate: Date} +): Promise { + const [endPrices, startPrices] = await Promise.all([ + fetchClosingPrices(api, SP500_TICKERS, window.recentDate), + fetchClosingPrices(api, SP500_TICKERS, window.pastDate), + ]); + return rankByMomentum(endPrices, startPrices); +} From 9587843b97b147ecf923ed20daa1593fb93f9b86 Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Sat, 27 Jun 2026 13:34:34 +0200 Subject: [PATCH 2/3] feat(trading-strategies): add a free TipRanks scorer and make the rotation provider-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the momentum rotation run on free TipRanks data instead of paid FMP. - exchange: new TipRanksAPI client. The TipRanks MCP is a stateless HTTP RPC, so a single tools/call POST returns the data (no SDK or session). Two batch calls (getAssetsData + getTechnicalAnalysis) cover the whole scorecard. - TipRanksScorecard: orchestrator that feeds TipRanks data into the existing pure computeTipRanksScorecard, handling nulls. - MomentumRotation now takes a PortfolioScorer (a rank() method) instead of a fixed FMP scorecard, so either provider can drive it. Added a matching rank() to the FMP MomentumScorecard. Trade-off: the TipRanks rubric is backward-looking (no forward P/E, growth, revision, or falling-knife guard) and its quotes can lag, so it trades a weaker basket — free, but not as sharp as FMP. Verified end to end with a read-only plan(). --- packages/exchange/src/data/index.ts | 1 + .../exchange/src/data/tipranks/TipRanksAPI.ts | 90 +++++++++++++++++++ packages/exchange/src/data/tipranks/index.ts | 3 + .../tipranks/schema/TipRanksAssetSchema.ts | 13 +++ .../schema/TipRanksTechnicalSchema.ts | 16 ++++ .../src/rotation/MomentumRotation.ts | 23 +++-- .../src/scorecard/MomentumScorecard.ts | 6 ++ .../src/scorecard/TipRanksScorecard.ts | 60 +++++++++++++ .../trading-strategies/src/scorecard/index.ts | 1 + 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 packages/exchange/src/data/tipranks/TipRanksAPI.ts create mode 100644 packages/exchange/src/data/tipranks/index.ts create mode 100644 packages/exchange/src/data/tipranks/schema/TipRanksAssetSchema.ts create mode 100644 packages/exchange/src/data/tipranks/schema/TipRanksTechnicalSchema.ts create mode 100644 packages/trading-strategies/src/scorecard/TipRanksScorecard.ts diff --git a/packages/exchange/src/data/index.ts b/packages/exchange/src/data/index.ts index f50d17564..49ca6e254 100644 --- a/packages/exchange/src/data/index.ts +++ b/packages/exchange/src/data/index.ts @@ -1 +1,2 @@ export * from './fmp/index.js'; +export * from './tipranks/index.js'; diff --git a/packages/exchange/src/data/tipranks/TipRanksAPI.ts b/packages/exchange/src/data/tipranks/TipRanksAPI.ts new file mode 100644 index 000000000..9056730a6 --- /dev/null +++ b/packages/exchange/src/data/tipranks/TipRanksAPI.ts @@ -0,0 +1,90 @@ +import axios from 'axios'; +import axiosRetry from 'axios-retry'; +import {ms} from 'ms'; +import {z} from 'zod'; +import {TipRanksAssetSchema} from './schema/TipRanksAssetSchema.js'; +import {TipRanksTechnicalSchema} from './schema/TipRanksTechnicalSchema.js'; +import {simplifyError} from '../../util/simplifyError.js'; + +const EnvelopeSchema = z.looseObject({ + error: z.looseObject({message: z.string()}).optional(), + result: z + .looseObject({ + content: z.array(z.looseObject({text: z.string()})).optional(), + isError: z.boolean().optional(), + structuredContent: z.looseObject({result: z.string()}).optional(), + }) + .optional(), +}); + +/** + * Pulls the `data:` payload out of the MCP server's Server-Sent-Events response, unwraps the + * JSON-RPC envelope, and parses the tool result (which TipRanks returns as a JSON *string*). + */ +function parseToolResult(sse: string): unknown { + const payload = sse + .split('\n') + .filter(line => line.startsWith('data:')) + .map(line => line.slice('data:'.length).trim()) + .join(''); + + const envelope = EnvelopeSchema.parse(JSON.parse(payload)); + if (envelope.error) { + throw new Error(`TipRanks MCP error: ${envelope.error.message}`); + } + if (!envelope.result || envelope.result.isError) { + throw new Error('TipRanks MCP returned an error result.'); + } + const json = envelope.result.structuredContent?.result ?? envelope.result.content?.[0]?.text; + if (json === undefined) { + throw new Error('TipRanks MCP returned no content.'); + } + return JSON.parse(json); +} + +/** + * Read-only client for TipRanks' hosted MCP server. The server speaks MCP over a stateless HTTP + * transport: a single `tools/call` POST returns the result, so no SDK or session handshake is + * needed. The API key is sent as the `apikey` query parameter. + * + * @see https://mcp.tipranks.com + */ +export class TipRanksAPI { + readonly #client; + + constructor(options: {apiKey: string}) { + this.#client = axios.create({ + baseURL: 'https://mcp.tipranks.com', + headers: {Accept: 'application/json, text/event-stream', 'Content-Type': 'application/json'}, + params: {apikey: options.apiKey}, + responseType: 'text', + }); + axiosRetry(this.#client, { + retries: 3, + retryDelay: retryCount => retryCount * ms('1s'), + }); + simplifyError(this.#client); + } + + async #callTool(name: string, args: Record): Promise { + const response = await this.#client.post('/mcp', { + id: 1, + jsonrpc: '2.0', + method: 'tools/call', + params: {arguments: args, name}, + }); + return parseToolResult(response.data); + } + + /** Headline per-ticker data: price, Smart Score, analyst consensus, price target, trailing P/E. */ + async getAssetsData(tickers: string[]) { + const data = await this.#callTool('get_assets_data', {tickers: tickers.join(',')}); + return z.looseObject({assetsData: z.array(TipRanksAssetSchema)}).parse(data).assetsData; + } + + /** Technical analysis per ticker; used here for the simple 200-day moving average. */ + async getTechnicalAnalysis(tickers: string[]) { + const data = await this.#callTool('get_technical_analysis', {tickers: tickers.join(',')}); + return z.array(TipRanksTechnicalSchema).parse(data); + } +} diff --git a/packages/exchange/src/data/tipranks/index.ts b/packages/exchange/src/data/tipranks/index.ts new file mode 100644 index 000000000..01f3c0a17 --- /dev/null +++ b/packages/exchange/src/data/tipranks/index.ts @@ -0,0 +1,3 @@ +export * from './TipRanksAPI.js'; +export * from './schema/TipRanksAssetSchema.js'; +export * from './schema/TipRanksTechnicalSchema.js'; diff --git a/packages/exchange/src/data/tipranks/schema/TipRanksAssetSchema.ts b/packages/exchange/src/data/tipranks/schema/TipRanksAssetSchema.ts new file mode 100644 index 000000000..d70e326a9 --- /dev/null +++ b/packages/exchange/src/data/tipranks/schema/TipRanksAssetSchema.ts @@ -0,0 +1,13 @@ +import {z} from 'zod'; + +/** One entry from the TipRanks MCP `get_assets_data` tool (headline per-ticker data). */ +export const TipRanksAssetSchema = z.looseObject({ + analystConsensus: z.string().nullable(), + peRatio: z.number().nullable(), + price: z.number(), + priceTarget: z.number().nullable(), + smartScore: z.number().nullable(), + ticker: z.string(), +}); + +export type TipRanksAsset = z.infer; diff --git a/packages/exchange/src/data/tipranks/schema/TipRanksTechnicalSchema.ts b/packages/exchange/src/data/tipranks/schema/TipRanksTechnicalSchema.ts new file mode 100644 index 000000000..46dc1572f --- /dev/null +++ b/packages/exchange/src/data/tipranks/schema/TipRanksTechnicalSchema.ts @@ -0,0 +1,16 @@ +import {z} from 'zod'; + +/** + * One entry from the TipRanks MCP `get_technical_analysis` tool. Only the simple 200-day moving + * average is modelled here; the full payload (oscillators, pivots, other MAs) is ignored. + */ +export const TipRanksTechnicalSchema = z.looseObject({ + movingAveragesAnalysis: z.looseObject({ + simple: z.looseObject({ + mA200: z.looseObject({score: z.number()}), + }), + }), + ticker: z.string(), +}); + +export type TipRanksTechnical = z.infer; diff --git a/packages/trading-strategies/src/rotation/MomentumRotation.ts b/packages/trading-strategies/src/rotation/MomentumRotation.ts index 067b62014..aa5cc94aa 100644 --- a/packages/trading-strategies/src/rotation/MomentumRotation.ts +++ b/packages/trading-strategies/src/rotation/MomentumRotation.ts @@ -1,14 +1,21 @@ -import type {AlpacaAPI, FmpAPI} from '@typedtrader/exchange'; +import type {AlpacaAPI} from '@typedtrader/exchange'; import {computeRebalance, type Holding, type RebalancePlan} from './computeRebalance.js'; import {getExchangeYearMonth, getMomentumWindow} from '../report-sp500-momentum/SP500MomentumReport.js'; -import {MomentumScorecard} from '../scorecard/MomentumScorecard.js'; import {getMomentumRanking} from '../util/momentumRanking.js'; import {SP500_TIMEZONE} from '../util/sp500Tickers.js'; +/** + * Anything that can rank a set of tickers best-first. Both `MomentumScorecard` (FMP) and + * `TipRanksScorecard` satisfy it, so the rotation is agnostic to which data provider scores the picks. + */ +export interface PortfolioScorer { + rank(tickers: string[], now: Date): Promise; +} + export interface MomentumRotationConfig { /** Number of equal-weight positions to hold. */ positionCount: number; - /** Number of top momentum winners fed into the scorecard before picking the best `positionCount`. */ + /** Number of top momentum winners fed into the scorer before picking the best `positionCount`. */ momentumPoolSize: number; } @@ -29,12 +36,12 @@ const DEFAULT_CONFIG: MomentumRotationConfig = {momentumPoolSize: 20, positionCo */ export class MomentumRotation { readonly #alpaca: AlpacaAPI; - readonly #scorecard: MomentumScorecard; + readonly #scorer: PortfolioScorer; readonly #config: MomentumRotationConfig; - constructor(alpaca: AlpacaAPI, fmp: FmpAPI, config: Partial = {}) { + constructor(alpaca: AlpacaAPI, scorer: PortfolioScorer, config: Partial = {}) { this.#alpaca = alpaca; - this.#scorecard = new MomentumScorecard(fmp); + this.#scorer = scorer; this.#config = {...DEFAULT_CONFIG, ...config}; } @@ -43,8 +50,8 @@ export class MomentumRotation { const {month, year} = getExchangeYearMonth(now.toISOString(), SP500_TIMEZONE); const ranking = await getMomentumRanking(this.#alpaca, getMomentumWindow(year, month)); const winners = ranking.slice(0, this.#config.momentumPoolSize).map(result => result.ticker); - const scored = await this.#scorecard.build(winners, now); - return scored.slice(0, this.#config.positionCount).map(row => row.ticker); + const ranked = await this.#scorer.rank(winners, now); + return ranked.slice(0, this.#config.positionCount); } /** Builds the rebalance plan without placing any orders, so it can be reviewed or paper-traded first. */ diff --git a/packages/trading-strategies/src/scorecard/MomentumScorecard.ts b/packages/trading-strategies/src/scorecard/MomentumScorecard.ts index 6da965309..e44cffb38 100644 --- a/packages/trading-strategies/src/scorecard/MomentumScorecard.ts +++ b/packages/trading-strategies/src/scorecard/MomentumScorecard.ts @@ -19,6 +19,12 @@ export class MomentumScorecard { return computeScorecard(inputs); } + /** Scores the tickers and returns them ranked best-first, for callers that only need the order. */ + async rank(tickers: string[], now: Date): Promise { + const rows = await this.build(tickers, now); + return rows.map(row => row.ticker); + } + async #fetchInput(ticker: string, now: Date): Promise { const [quote, target, estimates, ratings, targetSummary] = await Promise.all([ this.#fmp.getQuote(ticker), diff --git a/packages/trading-strategies/src/scorecard/TipRanksScorecard.ts b/packages/trading-strategies/src/scorecard/TipRanksScorecard.ts new file mode 100644 index 000000000..d4e5b76db --- /dev/null +++ b/packages/trading-strategies/src/scorecard/TipRanksScorecard.ts @@ -0,0 +1,60 @@ +import type {TipRanksAPI} from '@typedtrader/exchange'; +import { + computeTipRanksScorecard, + type TipRanksScorecardInput, + type TipRanksScorecardRow, +} from './computeTipRanksScorecard.js'; + +/** + * Builds the TipRanks scorecard from live TipRanks MCP data. Two batch calls cover the whole list: + * `getAssetsData` (price, Smart Score, consensus, target, trailing P/E) and `getTechnicalAnalysis` + * (the 200-day moving average). All scoring lives in the pure {@link computeTipRanksScorecard}; this + * class is only I/O and null-handling. + * + * It is a free, backward-looking alternative to the FMP scorecard: no forward P/E, EPS growth, or + * revision/falling-knife signals, and TipRanks quotes can lag. Use it when cost matters more than + * those forward signals. + */ +export class TipRanksScorecard { + readonly #tipranks: TipRanksAPI; + + constructor(tipranks: TipRanksAPI) { + this.#tipranks = tipranks; + } + + /** Scores the tickers and returns them ranked best-first. */ + async rank(tickers: string[]): Promise { + const rows = await this.build(tickers); + return rows.map(row => row.ticker); + } + + async build(tickers: string[]): Promise { + const [assets, technicals] = await Promise.all([ + this.#tipranks.getAssetsData(tickers), + this.#tipranks.getTechnicalAnalysis(tickers), + ]); + + const movingAverageByTicker = new Map( + technicals.map(technical => [technical.ticker, technical.movingAveragesAnalysis.simple.mA200.score]) + ); + + const inputs: TipRanksScorecardInput[] = []; + for (const asset of assets) { + const movingAverage200 = movingAverageByTicker.get(asset.ticker); + if (movingAverage200 === undefined) { + continue; // no 200-day MA -> can't score extension, skip the ticker + } + inputs.push({ + consensus: asset.analystConsensus ?? 'Hold', + movingAverage200, + price: asset.price, + smartScore: asset.smartScore ?? 0, + targetConsensus: asset.priceTarget ?? asset.price, // no target -> 0% upside + ticker: asset.ticker, + trailingPE: asset.peRatio ?? 0, // null P/E (loss-making) -> scores as -1 + }); + } + + return computeTipRanksScorecard(inputs); + } +} diff --git a/packages/trading-strategies/src/scorecard/index.ts b/packages/trading-strategies/src/scorecard/index.ts index dd6248140..1d6d66c41 100644 --- a/packages/trading-strategies/src/scorecard/index.ts +++ b/packages/trading-strategies/src/scorecard/index.ts @@ -1,3 +1,4 @@ export * from './computeScorecard.js'; export * from './computeTipRanksScorecard.js'; export * from './MomentumScorecard.js'; +export * from './TipRanksScorecard.js'; From e422343df94a9553f24dcda2ba830b77d083143c Mon Sep 17 00:00:00 2001 From: Benny Neugebauer Date: Sat, 27 Jun 2026 16:45:05 +0200 Subject: [PATCH 3/3] feat(messaging): add momentum-rotation runner (npm run rotation) A one-shot entrypoint that runs the momentum rotation from env config. Reads keys from packages/exchange/.env (Alpaca + TipRanks/FMP), builds the rotation, and prints the plan. - Dry-run by default (read-only, no orders). Set ROTATION_EXECUTE=true to place them. - Scorer defaults to the free TipRanks; ROTATION_SCORER=fmp switches to FMP. - ALPACA_USE_PAPER=true targets the paper account. The rebalance is low-churn and idempotent, so it can be cron'd on any cadence (monthly is natural) and only trades when the top picks actually change. --- packages/messaging/package.json | 1 + packages/messaging/src/runMomentumRotation.ts | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/messaging/src/runMomentumRotation.ts diff --git a/packages/messaging/package.json b/packages/messaging/package.json index f8940cad0..58ab49888 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -54,6 +54,7 @@ "lint:other": "npm run prettier -- --list-different", "lint:types": "tsc --noEmit", "prettier": "prettier --config ../../.prettierrc.json --ignore-path ../../.gitignore --log-level error \"**/*.{json,scss,yml}\"", + "rotation": "tsx src/runMomentumRotation.ts", "start": "tsx src/start.ts", "test": "npm run test:types && npm run test:imports && npm run test:units -- --coverage", "test:imports": "depcruise -c ../../.dependency-cruiser.cjs src --include-only \"^src\"", diff --git a/packages/messaging/src/runMomentumRotation.ts b/packages/messaging/src/runMomentumRotation.ts new file mode 100644 index 000000000..6d4527283 --- /dev/null +++ b/packages/messaging/src/runMomentumRotation.ts @@ -0,0 +1,80 @@ +import {readFileSync} from 'node:fs'; +import {AlpacaAPI, FmpAPI, TipRanksAPI} from '@typedtrader/exchange'; +import {MomentumRotation, MomentumScorecard, TipRanksScorecard, type PortfolioScorer} from 'trading-strategies'; + +/** + * One-shot momentum-rotation runner. Schedule it however you like (monthly is the natural cadence, + * but the rebalance is low-churn and idempotent, so running it more often simply no-ops until the + * top picks actually change). + * + * Env vars (read from `packages/exchange/.env` or the process environment): + * ALPACA_LIVE_API_KEY, ALPACA_LIVE_API_SECRET required + * TIPRANKS_API_KEY required for the default (free) scorer + * FMP_API_KEY required when ROTATION_SCORER=fmp + * ROTATION_SCORER = "tipranks" (default) | "fmp" + * ALPACA_USE_PAPER = "true" to trade the paper account (default: live) + * ROTATION_EXECUTE = "true" to place orders (default: dry-run, no orders) + */ + +function loadExchangeEnv(): void { + try { + const text = readFileSync(new URL('../exchange/.env', `file://${process.cwd()}/`), 'utf8'); + for (const line of text.split('\n')) { + const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/); + if (match && process.env[match[1]] === undefined) { + process.env[match[1]] = match[2].replace(/^["']|["']$/g, ''); + } + } + } catch { + // No exchange/.env nearby; rely on whatever is already in process.env. + } +} + +function required(name: string) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +loadExchangeEnv(); + +const usePaper = process.env.ALPACA_USE_PAPER === 'true'; +const execute = process.env.ROTATION_EXECUTE === 'true'; +const scorerKind = process.env.ROTATION_SCORER === 'fmp' ? 'fmp' : 'tipranks'; + +const alpaca = new AlpacaAPI({ + apiKey: required('ALPACA_LIVE_API_KEY'), + apiSecret: required('ALPACA_LIVE_API_SECRET'), + usePaperTrading: usePaper, +}); + +const scorer: PortfolioScorer = + scorerKind === 'fmp' + ? new MomentumScorecard(new FmpAPI({apiKey: required('FMP_API_KEY')})) + : new TipRanksScorecard(new TipRanksAPI({apiKey: required('TIPRANKS_API_KEY')})); + +const rotation = new MomentumRotation(alpaca, scorer); + +console.log( + `Momentum rotation — scorer=${scorerKind}, account=${usePaper ? 'paper' : 'LIVE'}, mode=${execute ? 'EXECUTE' : 'dry-run'}` +); + +const {plan, targets} = execute ? await rotation.rebalance(new Date()) : await rotation.plan(new Date()); + +const money = (value: number) => `$${value.toFixed(0)}`; +console.log(`Target portfolio: ${targets.join(', ')}`); +console.log( + ` SELL: ${plan.sells.map(order => `${order.ticker} (${money(order.notionalUsd)})`).join(', ') || '(none)'}` +); +console.log( + ` BUY: ${plan.buys.map(order => `${order.ticker} (${money(order.notionalUsd)})`).join(', ') || '(none)'}` +); +console.log(` HOLD: ${plan.holds.join(', ') || '(none)'}`); + +if (execute) { + console.log(`\nOrders placed: ${plan.sells.length} sells, ${plan.buys.length} buys.`); +} else { + console.log('\nDry-run only — no orders placed. Set ROTATION_EXECUTE=true to trade.'); +}