Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/exchange/src/data/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './fmp/index.js';
export * from './tipranks/index.js';
90 changes: 90 additions & 0 deletions packages/exchange/src/data/tipranks/TipRanksAPI.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<unknown> {
const response = await this.#client.post<string>('/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);
}
}
3 changes: 3 additions & 0 deletions packages/exchange/src/data/tipranks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './TipRanksAPI.js';
export * from './schema/TipRanksAssetSchema.js';
export * from './schema/TipRanksTechnicalSchema.js';
13 changes: 13 additions & 0 deletions packages/exchange/src/data/tipranks/schema/TipRanksAssetSchema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TipRanksAssetSchema>;
Original file line number Diff line number Diff line change
@@ -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<typeof TipRanksTechnicalSchema>;
1 change: 1 addition & 0 deletions packages/messaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
80 changes: 80 additions & 0 deletions packages/messaging/src/runMomentumRotation.ts
Original file line number Diff line number Diff line change
@@ -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.');
}
1 change: 1 addition & 0 deletions packages/trading-strategies/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@ 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';

export const SP500MomentumSchema = z.object({});

export type SP500MomentumConfig = z.infer<typeof SP500MomentumSchema>;

interface MomentumResult {
ticker: string;
priceNow: number;
price12MonthsAgo: number;
returnPct: number;
}

const BATCH_SIZE = 1000;

const WEEKEND_SHIFT_TO_MONDAY: Record<number, number> = {
0: 1, // Sunday → Monday
6: 2, // Saturday → Monday
Expand Down Expand Up @@ -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<string, number>, startPrices: Map<string, number>): 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<string, number>();
Expand Down Expand Up @@ -143,61 +116,17 @@ export class SP500MomentumReport extends Report<SP500MomentumConfig> {
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<Map<string, number>> {
const prices = new Map<string, number>();

// 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<Map<string, number>> {
const result = new Map<string, number>();

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<string, string>) {
const top = 20;
const lines: string[] = [];
Expand Down
Loading