nothumanallowed 15.1.67 → 15.1.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/constants.mjs +1 -1
- package/src/services/tool-executor.mjs +559 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.1.
|
|
3
|
+
"version": "15.1.68",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '15.1.
|
|
8
|
+
export const VERSION = '15.1.68';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -96,6 +96,8 @@ export const DESTRUCTIVE_ACTIONS = new Set([
|
|
|
96
96
|
'alexandria_send',
|
|
97
97
|
// Cron / scheduling
|
|
98
98
|
'cron_create', 'cron_delete',
|
|
99
|
+
// Portfolio (local file)
|
|
100
|
+
'portfolio_add', 'portfolio_remove',
|
|
99
101
|
]);
|
|
100
102
|
|
|
101
103
|
// ── Tool Definitions (for system prompt) ─────────────────────────────────────
|
|
@@ -661,6 +663,63 @@ TOOLS:
|
|
|
661
663
|
query: free-text like "Federal Reserve inflation", "AI chips", "earnings season".
|
|
662
664
|
Returns: headline, source, publish time, and URL for each article.
|
|
663
665
|
|
|
666
|
+
88. earnings_calendar(ticker: string, days?: number)
|
|
667
|
+
Next earnings date + EPS/revenue estimates + last 4 quarters surprise history + analyst trend for next quarter.
|
|
668
|
+
Use BEFORE any near-term trade idea — earnings move stocks more than fundamentals.
|
|
669
|
+
|
|
670
|
+
89. dividend_calendar(ticker: string)
|
|
671
|
+
Dividend yield, payout ratio, next ex-dividend date, next pay date, 5y average yield.
|
|
672
|
+
|
|
673
|
+
90. economic_calendar(country?: string, days?: number)
|
|
674
|
+
Upcoming macroeconomic releases (FOMC, ECB, CPI, NFP, etc.) for a country. Defaults: US, 7 days.
|
|
675
|
+
country codes: "US" | "EU" | "IT" | "DE" | "FR" | "UK" | "JP" | "CN". Returns time, importance ★/★★/★★★, forecast vs previous.
|
|
676
|
+
|
|
677
|
+
91. stock_screener(screen?: string, count?: number)
|
|
678
|
+
Pre-built Yahoo Finance screeners. screen values:
|
|
679
|
+
most_actives | day_gainers | day_losers | undervalued_growth_stocks | growth_technology_stocks |
|
|
680
|
+
aggressive_small_caps | small_cap_gainers | undervalued_large_caps | conservative_foreign_funds |
|
|
681
|
+
high_yield_bond | portfolio_anchors | top_mutual_funds.
|
|
682
|
+
Returns sym/name/price/change%/mcap/P/E for the top N matches.
|
|
683
|
+
|
|
684
|
+
92. peer_comparison(ticker: string)
|
|
685
|
+
Identify direct peers via Yahoo recommendations + side-by-side comparison of P/E, P/B, ROE, D/E, dividend yield.
|
|
686
|
+
Use to position a stock vs its industry, NOT just vs the broad market.
|
|
687
|
+
|
|
688
|
+
93. sec_filings(ticker: string, form?: string, limit?: number)
|
|
689
|
+
SEC EDGAR filings for a US-listed company. form filter: "10-K" | "10-Q" | "8-K" | "DEF 14A" | "4" (insider) | "13F-HR" etc.
|
|
690
|
+
Each row: date · form · description + direct URL to the filing document. Default: latest 10 of any form.
|
|
691
|
+
|
|
692
|
+
94. options_chain(ticker: string)
|
|
693
|
+
Options chain for nearest expiry: top 10 calls and puts by strike with bid/ask/last/IV/OI.
|
|
694
|
+
Lists all available expirations. Use for IV analysis, options strategy sizing, gamma proximity.
|
|
695
|
+
|
|
696
|
+
95. portfolio_add(ticker: string, qty: number, cost?: number)
|
|
697
|
+
Add (or average-down) a stock position to the local portfolio (~/.nha/portfolio.json). cost = price/share.
|
|
698
|
+
|
|
699
|
+
96. portfolio_remove(ticker: string)
|
|
700
|
+
Remove a position completely from the portfolio.
|
|
701
|
+
|
|
702
|
+
97. portfolio_summary()
|
|
703
|
+
Live snapshot of all positions: qty, cost, current price, value, P/L $, P/L %, plus aggregate totals.
|
|
704
|
+
|
|
705
|
+
98. portfolio_metrics(period?: string)
|
|
706
|
+
Quant metrics computed from historical prices: annualized return, volatility, Sharpe, Sortino, max drawdown, beta vs SPY.
|
|
707
|
+
period: "1mo" "3mo" "6mo" "1y" "2y" "5y" "10y". Default: "1y". Assumes 4% risk-free rate.
|
|
708
|
+
|
|
709
|
+
99. news_sentiment(ticker?: string, query?: string)
|
|
710
|
+
Fetch ~15 recent headlines and score each (positive/neutral/negative) via LLM. Returns aggregate verdict (🟢/🟡/🔴),
|
|
711
|
+
distribution, and the top 5 most signal-bearing headlines with one-line reasoning.
|
|
712
|
+
|
|
713
|
+
100. backtest_strategy(ticker?: string, period?: string, strategy?: string)
|
|
714
|
+
Run a parametric backtest. strategy values:
|
|
715
|
+
- "sma_crossover": go long when SMA-20 > SMA-50, flat otherwise
|
|
716
|
+
- "rsi_meanrev": buy oversold (<30), sell overbought (>70), hold otherwise
|
|
717
|
+
- "buy_hold": baseline
|
|
718
|
+
Returns total return, annualized return, vol, Sharpe, max drawdown. For custom strategies use execute_code directly.
|
|
719
|
+
|
|
720
|
+
101. italian_market(what?: string)
|
|
721
|
+
Italian market snapshot. what: "all" | "mib" (FTSE MIB index) | "constituents" (top 15 blue chips with live prices) | "spread" (BTP-Bund 10y).
|
|
722
|
+
|
|
664
723
|
--- CODE EXECUTION ---
|
|
665
724
|
|
|
666
725
|
81. execute_code(language: "python"|"javascript"|"typescript", code: string, files?: [{path: string, content: string}], packages?: string[], stdin?: string, timeout?: number)
|
|
@@ -3945,6 +4004,506 @@ export async function executeTool(action, params, config) {
|
|
|
3945
4004
|
}
|
|
3946
4005
|
}
|
|
3947
4006
|
|
|
4007
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4008
|
+
// FINANCIAL ANALYSIS — extended tool suite
|
|
4009
|
+
// All providers are free / public APIs (Yahoo Finance unofficial,
|
|
4010
|
+
// SEC EDGAR, CoinGecko, FRED with optional key). Tools return
|
|
4011
|
+
// human-readable text so an LLM can synthesize a report on top.
|
|
4012
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4013
|
+
|
|
4014
|
+
case 'earnings_calendar': {
|
|
4015
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4016
|
+
const days = Math.min(parseInt(params.days || '60', 10), 365);
|
|
4017
|
+
if (!ticker) return 'earnings_calendar: ticker required (e.g. "AAPL").';
|
|
4018
|
+
try {
|
|
4019
|
+
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=earnings,calendarEvents,earningsHistory,earningsTrend`;
|
|
4020
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4021
|
+
if (!res.ok) return `earnings_calendar: HTTP ${res.status}`;
|
|
4022
|
+
const d = await res.json();
|
|
4023
|
+
const summary = d?.quoteSummary?.result?.[0] || {};
|
|
4024
|
+
const cal = summary.calendarEvents?.earnings || {};
|
|
4025
|
+
const hist = summary.earningsHistory?.history || [];
|
|
4026
|
+
const trend = summary.earningsTrend?.trend || [];
|
|
4027
|
+
const lines = [`Earnings calendar — ${ticker}`];
|
|
4028
|
+
if (cal.earningsDate?.length) {
|
|
4029
|
+
const dates = cal.earningsDate.map(d => (d.fmt || d.raw)).filter(Boolean).join(' – ');
|
|
4030
|
+
lines.push(`Next earnings: ${dates}${cal.isEarningsDateEstimate ? ' (estimated)' : ''}`);
|
|
4031
|
+
if (cal.earningsAverage?.fmt) lines.push(` EPS estimate: ${cal.earningsAverage.fmt} (low ${cal.earningsLow?.fmt || '?'} / high ${cal.earningsHigh?.fmt || '?'})`);
|
|
4032
|
+
if (cal.revenueAverage?.fmt) lines.push(` Revenue estimate: ${cal.revenueAverage.fmt}`);
|
|
4033
|
+
}
|
|
4034
|
+
if (hist.length) {
|
|
4035
|
+
lines.push('\nHistory (last quarters): est → actual (surprise %)');
|
|
4036
|
+
hist.slice(0, 4).forEach(h => {
|
|
4037
|
+
const est = h.epsEstimate?.fmt ?? '?';
|
|
4038
|
+
const act = h.epsActual?.fmt ?? '?';
|
|
4039
|
+
const surp = h.surprisePercent?.fmt ?? '?';
|
|
4040
|
+
lines.push(` ${h.quarter?.fmt || '?'}: ${est} → ${act} (${surp})`);
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
if (trend.length) {
|
|
4044
|
+
const next = trend.find(t => t.period === '+1q') || trend[0];
|
|
4045
|
+
if (next?.earningsEstimate?.avg?.fmt) {
|
|
4046
|
+
lines.push(`\nAnalyst trend next Q: avg EPS ${next.earningsEstimate.avg.fmt}, growth ${next.earningsEstimate.growth?.fmt || '?'} (${next.earningsEstimate.numberOfAnalysts?.fmt || '?'} analysts)`);
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
return lines.join('\n');
|
|
4050
|
+
} catch (e) { return `earnings_calendar error: ${e.message}`; }
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
case 'dividend_calendar': {
|
|
4054
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4055
|
+
if (!ticker) return 'dividend_calendar: ticker required.';
|
|
4056
|
+
try {
|
|
4057
|
+
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=summaryDetail,calendarEvents`;
|
|
4058
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4059
|
+
if (!res.ok) return `dividend_calendar: HTTP ${res.status}`;
|
|
4060
|
+
const d = await res.json();
|
|
4061
|
+
const sd = d?.quoteSummary?.result?.[0]?.summaryDetail || {};
|
|
4062
|
+
const cal = d?.quoteSummary?.result?.[0]?.calendarEvents || {};
|
|
4063
|
+
const lines = [`Dividend — ${ticker}`];
|
|
4064
|
+
lines.push(`Yield: ${sd.dividendYield?.fmt || 'n/a'}${sd.trailingAnnualDividendRate?.fmt ? ` (${sd.trailingAnnualDividendRate.fmt}/yr)` : ''}`);
|
|
4065
|
+
lines.push(`Payout ratio: ${sd.payoutRatio?.fmt || 'n/a'}`);
|
|
4066
|
+
if (cal.exDividendDate?.fmt) lines.push(`Next ex-dividend: ${cal.exDividendDate.fmt}`);
|
|
4067
|
+
if (cal.dividendDate?.fmt) lines.push(`Next pay date: ${cal.dividendDate.fmt}`);
|
|
4068
|
+
if (sd.fiveYearAvgDividendYield?.fmt) lines.push(`5y avg yield: ${sd.fiveYearAvgDividendYield.fmt}`);
|
|
4069
|
+
return lines.join('\n');
|
|
4070
|
+
} catch (e) { return `dividend_calendar error: ${e.message}`; }
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
case 'economic_calendar': {
|
|
4074
|
+
const days = Math.min(parseInt(params.days || '7', 10), 30);
|
|
4075
|
+
const country = String(params.country || 'US').toUpperCase();
|
|
4076
|
+
try {
|
|
4077
|
+
// Use Trading Economics public calendar feed (free tier, JSON, no key).
|
|
4078
|
+
const today = new Date();
|
|
4079
|
+
const to = new Date(today.getTime() + days * 86400000);
|
|
4080
|
+
const fmt = (d) => d.toISOString().slice(0, 10);
|
|
4081
|
+
const url = `https://api.tradingeconomics.com/calendar/country/${encodeURIComponent(country)}?d1=${fmt(today)}&d2=${fmt(to)}&c=guest:guest&f=json`;
|
|
4082
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'NHA/1.0', 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000) });
|
|
4083
|
+
if (!res.ok) return `economic_calendar: HTTP ${res.status} — try country code like "US", "EU", "IT", "DE".`;
|
|
4084
|
+
const events = await res.json();
|
|
4085
|
+
if (!Array.isArray(events) || events.length === 0) return `economic_calendar: no events in next ${days} days for ${country}.`;
|
|
4086
|
+
const lines = [`Economic calendar — ${country}, next ${days} days (${events.length} events)`];
|
|
4087
|
+
events.slice(0, 30).forEach(e => {
|
|
4088
|
+
const when = (e.Date || '').slice(0, 16).replace('T', ' ');
|
|
4089
|
+
const imp = e.Importance === 3 ? '★★★' : e.Importance === 2 ? '★★' : '★';
|
|
4090
|
+
lines.push(` ${when} ${imp} ${e.Event || e.Category}: forecast ${e.Forecast ?? '?'} | previous ${e.Previous ?? '?'}${e.Actual != null ? ` | actual ${e.Actual}` : ''}`);
|
|
4091
|
+
});
|
|
4092
|
+
return lines.join('\n');
|
|
4093
|
+
} catch (e) { return `economic_calendar error: ${e.message}`; }
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
case 'stock_screener': {
|
|
4097
|
+
// Yahoo Finance has a public "screener" endpoint. We use it via the
|
|
4098
|
+
// predefined-screener IDs (no key required). For complex filtering on
|
|
4099
|
+
// arbitrary criteria the LLM should chain calls (e.g. screener →
|
|
4100
|
+
// peer_comparison → market_indicators on each result).
|
|
4101
|
+
const screen = String(params.screen || 'most_actives').toLowerCase();
|
|
4102
|
+
const count = Math.min(parseInt(params.count || '20', 10), 50);
|
|
4103
|
+
// Known screen IDs: most_actives, day_gainers, day_losers, undervalued_growth_stocks,
|
|
4104
|
+
// growth_technology_stocks, aggressive_small_caps, small_cap_gainers, undervalued_large_caps,
|
|
4105
|
+
// conservative_foreign_funds, high_yield_bond, portfolio_anchors, solid_large_growth_funds,
|
|
4106
|
+
// solid_midcap_growth_funds, top_mutual_funds.
|
|
4107
|
+
try {
|
|
4108
|
+
const url = `https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved?formatted=true&lang=en-US®ion=US&scrIds=${encodeURIComponent(screen)}&count=${count}`;
|
|
4109
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)', 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000) });
|
|
4110
|
+
if (!res.ok) return `stock_screener: HTTP ${res.status} — known screens: most_actives, day_gainers, day_losers, undervalued_growth_stocks, growth_technology_stocks, aggressive_small_caps, small_cap_gainers, undervalued_large_caps.`;
|
|
4111
|
+
const d = await res.json();
|
|
4112
|
+
const quotes = d?.finance?.result?.[0]?.quotes || [];
|
|
4113
|
+
if (!quotes.length) return `stock_screener: no results for "${screen}".`;
|
|
4114
|
+
const lines = [`Stock screener — ${screen} (${quotes.length} results)`];
|
|
4115
|
+
quotes.forEach((q, i) => {
|
|
4116
|
+
const sym = q.symbol;
|
|
4117
|
+
const name = (q.shortName || q.longName || '').slice(0, 40);
|
|
4118
|
+
const price = q.regularMarketPrice?.fmt || q.regularMarketPrice;
|
|
4119
|
+
const chg = q.regularMarketChangePercent?.fmt || `${q.regularMarketChangePercent?.toFixed?.(2)}%`;
|
|
4120
|
+
const mcap = q.marketCap?.fmt || '?';
|
|
4121
|
+
const pe = q.trailingPE?.fmt || q.forwardPE?.fmt || '?';
|
|
4122
|
+
lines.push(`${i + 1}. ${sym} (${name}) — $${price} ${chg} | mcap ${mcap} | P/E ${pe}`);
|
|
4123
|
+
});
|
|
4124
|
+
return lines.join('\n');
|
|
4125
|
+
} catch (e) { return `stock_screener error: ${e.message}`; }
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
case 'peer_comparison': {
|
|
4129
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4130
|
+
if (!ticker) return 'peer_comparison: ticker required.';
|
|
4131
|
+
try {
|
|
4132
|
+
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=recommendationTrend,upgradeDowngradeHistory,assetProfile,summaryProfile`;
|
|
4133
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4134
|
+
if (!res.ok) return `peer_comparison: HTTP ${res.status}`;
|
|
4135
|
+
const d = await res.json();
|
|
4136
|
+
const profile = d?.quoteSummary?.result?.[0]?.assetProfile || d?.quoteSummary?.result?.[0]?.summaryProfile || {};
|
|
4137
|
+
const peersUrl = `https://query1.finance.yahoo.com/v1/finance/recommendationsbysymbol/${encodeURIComponent(ticker)}`;
|
|
4138
|
+
const peerRes = await fetch(peersUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4139
|
+
const peerData = peerRes.ok ? await peerRes.json() : null;
|
|
4140
|
+
const peers = peerData?.finance?.result?.[0]?.recommendedSymbols?.map(s => s.symbol) || [];
|
|
4141
|
+
const lines = [`Peer comparison — ${ticker}`];
|
|
4142
|
+
if (profile.industry) lines.push(`Industry: ${profile.industry}${profile.sector ? ` · ${profile.sector}` : ''}`);
|
|
4143
|
+
if (peers.length === 0) return lines.join('\n') + '\nNo peer suggestions available.';
|
|
4144
|
+
lines.push(`\nPeers: ${peers.join(', ')}\n`);
|
|
4145
|
+
// Compare key metrics for ticker + first 5 peers.
|
|
4146
|
+
const symbols = [ticker, ...peers.slice(0, 5)];
|
|
4147
|
+
const batch = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${symbols.join(',')}&fields=regularMarketPrice,marketCap,trailingPE,forwardPE,priceToBook,returnOnEquity,profitMargins,debtToEquity,dividendYield`;
|
|
4148
|
+
const bres = await fetch(batch, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4149
|
+
if (!bres.ok) return lines.join('\n');
|
|
4150
|
+
const bd = await bres.json();
|
|
4151
|
+
const rows = bd?.quoteResponse?.result || [];
|
|
4152
|
+
const fmt = (v) => v == null ? '?' : (typeof v === 'number' ? v.toFixed(2) : v);
|
|
4153
|
+
const header = `${'Ticker'.padEnd(10)}${'Price'.padStart(10)}${'P/E (ttm)'.padStart(12)}${'P/E (fwd)'.padStart(12)}${'P/B'.padStart(10)}${'ROE %'.padStart(10)}${'D/E'.padStart(10)}${'DivYld %'.padStart(11)}`;
|
|
4154
|
+
lines.push(header);
|
|
4155
|
+
rows.forEach(r => {
|
|
4156
|
+
lines.push(
|
|
4157
|
+
`${r.symbol.padEnd(10)}${String(fmt(r.regularMarketPrice)).padStart(10)}${String(fmt(r.trailingPE)).padStart(12)}${String(fmt(r.forwardPE)).padStart(12)}${String(fmt(r.priceToBook)).padStart(10)}${String(fmt(r.returnOnEquity ? (r.returnOnEquity * 100) : null)).padStart(10)}${String(fmt(r.debtToEquity)).padStart(10)}${String(fmt(r.dividendYield ? (r.dividendYield * 100) : null)).padStart(11)}`
|
|
4158
|
+
);
|
|
4159
|
+
});
|
|
4160
|
+
return lines.join('\n');
|
|
4161
|
+
} catch (e) { return `peer_comparison error: ${e.message}`; }
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
case 'sec_filings': {
|
|
4165
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4166
|
+
const formType = String(params.form || '').toUpperCase();
|
|
4167
|
+
const limit = Math.min(parseInt(params.limit || '10', 10), 25);
|
|
4168
|
+
if (!ticker) return 'sec_filings: ticker required.';
|
|
4169
|
+
try {
|
|
4170
|
+
// SEC EDGAR requires a User-Agent with contact info per their TOS.
|
|
4171
|
+
const ua = 'NotHumanAllowed CLI hello@nothumanallowed.com';
|
|
4172
|
+
// Step 1: ticker → CIK
|
|
4173
|
+
const tickersRes = await fetch('https://www.sec.gov/files/company_tickers.json', { headers: { 'User-Agent': ua } });
|
|
4174
|
+
if (!tickersRes.ok) return `sec_filings: failed to map ticker (HTTP ${tickersRes.status})`;
|
|
4175
|
+
const map = await tickersRes.json();
|
|
4176
|
+
const entry = Object.values(map).find(e => e.ticker?.toUpperCase() === ticker);
|
|
4177
|
+
if (!entry) return `sec_filings: ticker ${ticker} not found in SEC EDGAR (only US-listed companies).`;
|
|
4178
|
+
const cik = String(entry.cik_str).padStart(10, '0');
|
|
4179
|
+
// Step 2: fetch filings list for that CIK
|
|
4180
|
+
const fres = await fetch(`https://data.sec.gov/submissions/CIK${cik}.json`, { headers: { 'User-Agent': ua } });
|
|
4181
|
+
if (!fres.ok) return `sec_filings: HTTP ${fres.status}`;
|
|
4182
|
+
const fd = await fres.json();
|
|
4183
|
+
const recent = fd.filings?.recent || {};
|
|
4184
|
+
const rows = [];
|
|
4185
|
+
for (let i = 0; i < (recent.form?.length || 0); i++) {
|
|
4186
|
+
const form = recent.form[i];
|
|
4187
|
+
if (formType && form !== formType) continue;
|
|
4188
|
+
rows.push({
|
|
4189
|
+
form,
|
|
4190
|
+
date: recent.filingDate[i],
|
|
4191
|
+
accession: recent.accessionNumber[i],
|
|
4192
|
+
primary: recent.primaryDocument[i],
|
|
4193
|
+
description: recent.primaryDocDescription[i] || '',
|
|
4194
|
+
});
|
|
4195
|
+
if (rows.length >= limit) break;
|
|
4196
|
+
}
|
|
4197
|
+
if (rows.length === 0) return `sec_filings: no ${formType || ''} filings for ${ticker}.`;
|
|
4198
|
+
const lines = [`SEC filings — ${entry.title} (${ticker}) — CIK ${cik}`];
|
|
4199
|
+
rows.forEach(r => {
|
|
4200
|
+
const accNoDash = r.accession.replace(/-/g, '');
|
|
4201
|
+
const url = `https://www.sec.gov/Archives/edgar/data/${parseInt(cik, 10)}/${accNoDash}/${r.primary}`;
|
|
4202
|
+
lines.push(` ${r.date} ${r.form.padEnd(6)} ${r.description || ''}\n ${url}`);
|
|
4203
|
+
});
|
|
4204
|
+
return lines.join('\n');
|
|
4205
|
+
} catch (e) { return `sec_filings error: ${e.message}`; }
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
case 'options_chain': {
|
|
4209
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4210
|
+
if (!ticker) return 'options_chain: ticker required.';
|
|
4211
|
+
try {
|
|
4212
|
+
const url = `https://query2.finance.yahoo.com/v7/finance/options/${encodeURIComponent(ticker)}`;
|
|
4213
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4214
|
+
if (!res.ok) return `options_chain: HTTP ${res.status}`;
|
|
4215
|
+
const d = await res.json();
|
|
4216
|
+
const result = d?.optionChain?.result?.[0];
|
|
4217
|
+
if (!result) return `options_chain: no options data for ${ticker}.`;
|
|
4218
|
+
const exps = (result.expirationDates || []).slice(0, 6);
|
|
4219
|
+
const quote = result.quote || {};
|
|
4220
|
+
const chain = result.options?.[0] || {};
|
|
4221
|
+
const lines = [`Options chain — ${ticker} @ $${quote.regularMarketPrice}`];
|
|
4222
|
+
lines.push(`Expirations available: ${exps.map(t => new Date(t * 1000).toISOString().slice(0, 10)).join(', ')}`);
|
|
4223
|
+
const calls = (chain.calls || []).slice(0, 10);
|
|
4224
|
+
const puts = (chain.puts || []).slice(0, 10);
|
|
4225
|
+
if (calls.length) {
|
|
4226
|
+
lines.push(`\nCalls (top ${calls.length} by strike, exp ${new Date(chain.expirationDate * 1000).toISOString().slice(0, 10)}):`);
|
|
4227
|
+
calls.forEach(c => lines.push(` K=$${c.strike} bid=${c.bid} ask=${c.ask} last=${c.lastPrice} IV=${(c.impliedVolatility * 100).toFixed(1)}% OI=${c.openInterest}`));
|
|
4228
|
+
}
|
|
4229
|
+
if (puts.length) {
|
|
4230
|
+
lines.push(`\nPuts (top ${puts.length} by strike):`);
|
|
4231
|
+
puts.forEach(p => lines.push(` K=$${p.strike} bid=${p.bid} ask=${p.ask} last=${p.lastPrice} IV=${(p.impliedVolatility * 100).toFixed(1)}% OI=${p.openInterest}`));
|
|
4232
|
+
}
|
|
4233
|
+
return lines.join('\n');
|
|
4234
|
+
} catch (e) { return `options_chain error: ${e.message}`; }
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
case 'portfolio_add': {
|
|
4238
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4239
|
+
const qty = parseFloat(params.qty || params.quantity || '0');
|
|
4240
|
+
const cost = parseFloat(params.cost || params.price || '0');
|
|
4241
|
+
if (!ticker || qty <= 0) return 'portfolio_add: ticker and qty (>0) required.';
|
|
4242
|
+
const fs = await import('fs'); const path = await import('path'); const os = await import('os');
|
|
4243
|
+
const file = path.default.join(os.default.homedir(), '.nha', 'portfolio.json');
|
|
4244
|
+
let pf = { positions: [], cash: 0 };
|
|
4245
|
+
try { if (fs.default.existsSync(file)) pf = JSON.parse(fs.default.readFileSync(file, 'utf-8')); } catch {}
|
|
4246
|
+
pf.positions = pf.positions || [];
|
|
4247
|
+
const existing = pf.positions.find(p => p.ticker === ticker);
|
|
4248
|
+
if (existing) {
|
|
4249
|
+
const totalQty = existing.qty + qty;
|
|
4250
|
+
const avgCost = ((existing.qty * existing.cost) + (qty * cost)) / totalQty;
|
|
4251
|
+
existing.qty = totalQty;
|
|
4252
|
+
existing.cost = avgCost;
|
|
4253
|
+
} else {
|
|
4254
|
+
pf.positions.push({ ticker, qty, cost, addedAt: new Date().toISOString() });
|
|
4255
|
+
}
|
|
4256
|
+
fs.default.mkdirSync(path.default.dirname(file), { recursive: true });
|
|
4257
|
+
fs.default.writeFileSync(file, JSON.stringify(pf, null, 2));
|
|
4258
|
+
return `Portfolio updated: ${ticker} qty=${qty}${cost ? ` @ avg cost $${cost}` : ''}. Total positions: ${pf.positions.length}.`;
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
case 'portfolio_remove': {
|
|
4262
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4263
|
+
if (!ticker) return 'portfolio_remove: ticker required.';
|
|
4264
|
+
const fs = await import('fs'); const path = await import('path'); const os = await import('os');
|
|
4265
|
+
const file = path.default.join(os.default.homedir(), '.nha', 'portfolio.json');
|
|
4266
|
+
let pf = { positions: [] };
|
|
4267
|
+
try { if (fs.default.existsSync(file)) pf = JSON.parse(fs.default.readFileSync(file, 'utf-8')); } catch {}
|
|
4268
|
+
const before = (pf.positions || []).length;
|
|
4269
|
+
pf.positions = (pf.positions || []).filter(p => p.ticker !== ticker);
|
|
4270
|
+
if (pf.positions.length === before) return `Portfolio: ${ticker} not found.`;
|
|
4271
|
+
fs.default.writeFileSync(file, JSON.stringify(pf, null, 2));
|
|
4272
|
+
return `Portfolio: ${ticker} removed.`;
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
case 'portfolio_summary': {
|
|
4276
|
+
const fs = await import('fs'); const path = await import('path'); const os = await import('os');
|
|
4277
|
+
const file = path.default.join(os.default.homedir(), '.nha', 'portfolio.json');
|
|
4278
|
+
if (!fs.default.existsSync(file)) return 'Portfolio is empty. Add positions with portfolio_add.';
|
|
4279
|
+
const pf = JSON.parse(fs.default.readFileSync(file, 'utf-8'));
|
|
4280
|
+
const positions = pf.positions || [];
|
|
4281
|
+
if (positions.length === 0) return 'Portfolio is empty.';
|
|
4282
|
+
// Fetch live prices in batch
|
|
4283
|
+
const symbols = positions.map(p => p.ticker).join(',');
|
|
4284
|
+
const res = await fetch(`https://query1.finance.yahoo.com/v7/finance/quote?symbols=${symbols}`, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4285
|
+
if (!res.ok) return `portfolio_summary: HTTP ${res.status}`;
|
|
4286
|
+
const d = await res.json();
|
|
4287
|
+
const quotes = d?.quoteResponse?.result || [];
|
|
4288
|
+
const priceMap = Object.fromEntries(quotes.map(q => [q.symbol, q.regularMarketPrice]));
|
|
4289
|
+
let totalValue = 0, totalCost = 0;
|
|
4290
|
+
const rows = positions.map(p => {
|
|
4291
|
+
const price = priceMap[p.ticker] || 0;
|
|
4292
|
+
const value = price * p.qty;
|
|
4293
|
+
const cost = (p.cost || 0) * p.qty;
|
|
4294
|
+
const pl = value - cost;
|
|
4295
|
+
const plPct = cost > 0 ? (pl / cost) * 100 : 0;
|
|
4296
|
+
totalValue += value; totalCost += cost;
|
|
4297
|
+
return { ticker: p.ticker, qty: p.qty, costBasis: p.cost, currentPrice: price, value, pl, plPct };
|
|
4298
|
+
});
|
|
4299
|
+
const totalPL = totalValue - totalCost;
|
|
4300
|
+
const totalPLPct = totalCost > 0 ? (totalPL / totalCost) * 100 : 0;
|
|
4301
|
+
const lines = [`Portfolio summary — ${positions.length} positions`];
|
|
4302
|
+
lines.push(`${'Ticker'.padEnd(10)}${'Qty'.padStart(10)}${'Cost'.padStart(12)}${'Price'.padStart(12)}${'Value'.padStart(14)}${'P/L'.padStart(14)}${'P/L %'.padStart(10)}`);
|
|
4303
|
+
rows.forEach(r => {
|
|
4304
|
+
lines.push(`${r.ticker.padEnd(10)}${String(r.qty).padStart(10)}${('$' + (r.costBasis || 0).toFixed(2)).padStart(12)}${('$' + r.currentPrice.toFixed(2)).padStart(12)}${('$' + r.value.toFixed(2)).padStart(14)}${((r.pl >= 0 ? '+' : '') + '$' + r.pl.toFixed(2)).padStart(14)}${(r.plPct.toFixed(2) + '%').padStart(10)}`);
|
|
4305
|
+
});
|
|
4306
|
+
lines.push(`\nTotal value: $${totalValue.toFixed(2)} | Cost basis: $${totalCost.toFixed(2)} | P/L: ${totalPL >= 0 ? '+' : ''}$${totalPL.toFixed(2)} (${totalPLPct.toFixed(2)}%)`);
|
|
4307
|
+
return lines.join('\n');
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
case 'portfolio_metrics': {
|
|
4311
|
+
const period = params.period || '1y';
|
|
4312
|
+
const fs = await import('fs'); const path = await import('path'); const os = await import('os');
|
|
4313
|
+
const file = path.default.join(os.default.homedir(), '.nha', 'portfolio.json');
|
|
4314
|
+
if (!fs.default.existsSync(file)) return 'Portfolio is empty.';
|
|
4315
|
+
const pf = JSON.parse(fs.default.readFileSync(file, 'utf-8'));
|
|
4316
|
+
const positions = pf.positions || [];
|
|
4317
|
+
if (positions.length === 0) return 'Portfolio is empty.';
|
|
4318
|
+
// Fetch historical returns for each ticker → compute weighted portfolio returns,
|
|
4319
|
+
// then Sharpe, Sortino, max drawdown, beta vs SPY.
|
|
4320
|
+
const symbols = [...positions.map(p => p.ticker), 'SPY'];
|
|
4321
|
+
const fetchHist = async (sym) => {
|
|
4322
|
+
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?range=${period}&interval=1d`;
|
|
4323
|
+
const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4324
|
+
if (!r.ok) return null;
|
|
4325
|
+
const d = await r.json();
|
|
4326
|
+
return d?.chart?.result?.[0]?.indicators?.quote?.[0]?.close || null;
|
|
4327
|
+
};
|
|
4328
|
+
const series = {};
|
|
4329
|
+
for (const s of symbols) series[s] = await fetchHist(s);
|
|
4330
|
+
const valid = Object.values(series).filter(Array.isArray);
|
|
4331
|
+
if (valid.length === 0) return 'portfolio_metrics: failed to fetch historical data.';
|
|
4332
|
+
const N = Math.min(...valid.map(a => a.length));
|
|
4333
|
+
// Weighted returns: weight = current_value / total_value
|
|
4334
|
+
const lastPrice = (s) => (series[s] || []).slice(-1)[0];
|
|
4335
|
+
const weights = positions.map(p => p.qty * (lastPrice(p.ticker) || 0));
|
|
4336
|
+
const totalW = weights.reduce((a, b) => a + b, 0) || 1;
|
|
4337
|
+
const wNorm = weights.map(w => w / totalW);
|
|
4338
|
+
const dailyRet = (arr) => arr.slice(1).map((v, i) => (v - arr[i]) / arr[i]).filter(r => Number.isFinite(r));
|
|
4339
|
+
const tickerRets = positions.map(p => dailyRet(series[p.ticker] || []).slice(-N + 1));
|
|
4340
|
+
const minRetLen = Math.min(...tickerRets.map(r => r.length), dailyRet(series.SPY).length);
|
|
4341
|
+
const portfRets = [];
|
|
4342
|
+
for (let i = 0; i < minRetLen; i++) {
|
|
4343
|
+
let r = 0;
|
|
4344
|
+
for (let j = 0; j < tickerRets.length; j++) r += (tickerRets[j][i] || 0) * wNorm[j];
|
|
4345
|
+
portfRets.push(r);
|
|
4346
|
+
}
|
|
4347
|
+
const spyRets = dailyRet(series.SPY).slice(-minRetLen);
|
|
4348
|
+
const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length;
|
|
4349
|
+
const stdev = (a) => { const m = mean(a); return Math.sqrt(mean(a.map(x => (x - m) ** 2))); };
|
|
4350
|
+
const annRet = mean(portfRets) * 252 * 100;
|
|
4351
|
+
const annVol = stdev(portfRets) * Math.sqrt(252) * 100;
|
|
4352
|
+
const sharpe = annVol > 0 ? (annRet - 4) / annVol : 0; // assume 4% risk-free
|
|
4353
|
+
const downside = portfRets.filter(r => r < 0);
|
|
4354
|
+
const sortino = downside.length > 1 ? (annRet - 4) / (stdev(downside) * Math.sqrt(252) * 100) : 0;
|
|
4355
|
+
// Max drawdown
|
|
4356
|
+
const cum = []; let acc = 1;
|
|
4357
|
+
portfRets.forEach(r => { acc *= (1 + r); cum.push(acc); });
|
|
4358
|
+
let peak = -Infinity, maxDD = 0;
|
|
4359
|
+
cum.forEach(v => { peak = Math.max(peak, v); maxDD = Math.min(maxDD, (v - peak) / peak); });
|
|
4360
|
+
// Beta vs SPY
|
|
4361
|
+
const covMean = mean(portfRets.map((r, i) => (r - mean(portfRets)) * (spyRets[i] - mean(spyRets))));
|
|
4362
|
+
const varSpy = mean(spyRets.map(r => (r - mean(spyRets)) ** 2));
|
|
4363
|
+
const beta = varSpy > 0 ? covMean / varSpy : 0;
|
|
4364
|
+
return `Portfolio metrics (${period}, ${minRetLen} trading days):
|
|
4365
|
+
Annualized return: ${annRet.toFixed(2)}%
|
|
4366
|
+
Annualized volatility: ${annVol.toFixed(2)}%
|
|
4367
|
+
Sharpe ratio: ${sharpe.toFixed(2)} (assuming 4% RFR)
|
|
4368
|
+
Sortino ratio: ${sortino.toFixed(2)}
|
|
4369
|
+
Max drawdown: ${(maxDD * 100).toFixed(2)}%
|
|
4370
|
+
Beta vs SPY: ${beta.toFixed(2)}`;
|
|
4371
|
+
}
|
|
4372
|
+
|
|
4373
|
+
case 'news_sentiment': {
|
|
4374
|
+
const ticker = String(params.ticker || '').toUpperCase();
|
|
4375
|
+
const query = params.query || ticker;
|
|
4376
|
+
if (!ticker && !query) return 'news_sentiment: ticker or query required.';
|
|
4377
|
+
// 1. Pull recent news via the existing market_news tool
|
|
4378
|
+
const newsRes = await executeTool('market_news', { ticker, query, limit: 15 }, config);
|
|
4379
|
+
if (typeof newsRes !== 'string' || newsRes.startsWith('market_news error')) return newsRes;
|
|
4380
|
+
// 2. Sentiment-score each headline via LLM (single call, batched)
|
|
4381
|
+
const { callLLM } = await import('./llm.mjs');
|
|
4382
|
+
const sysPrompt = `You score financial-news headlines for sentiment. Output ONLY JSON array, no prose.\n` +
|
|
4383
|
+
`For each headline, output {"i": index, "s": "positive"|"neutral"|"negative", "c": confidence 0..1, "why": "<10 words"}.\n` +
|
|
4384
|
+
`Bullish/positive for the asset = "positive". Bearish/risk = "negative". Pure info = "neutral".`;
|
|
4385
|
+
const headlines = newsRes.split('\n').filter(l => /^\d+\./.test(l)).map((l, i) => `${i + 1}. ${l.replace(/^\d+\.\s*/, '').slice(0, 200)}`);
|
|
4386
|
+
const userMsg = headlines.join('\n');
|
|
4387
|
+
let scored;
|
|
4388
|
+
try {
|
|
4389
|
+
const raw = await callLLM(config, sysPrompt, userMsg, { temperature: 0, maxTokens: 800 });
|
|
4390
|
+
const m = raw.match(/\[[\s\S]*\]/);
|
|
4391
|
+
if (m) scored = JSON.parse(m[0]);
|
|
4392
|
+
} catch { /* fallthrough */ }
|
|
4393
|
+
if (!Array.isArray(scored)) return newsRes + '\n\n(sentiment scoring failed)';
|
|
4394
|
+
const counts = { positive: 0, neutral: 0, negative: 0 };
|
|
4395
|
+
const weightedSum = scored.reduce((acc, s) => { counts[s.s] = (counts[s.s] || 0) + 1; return acc + (s.s === 'positive' ? s.c : s.s === 'negative' ? -s.c : 0); }, 0);
|
|
4396
|
+
const avg = scored.length ? weightedSum / scored.length : 0;
|
|
4397
|
+
const verdict = avg > 0.2 ? '🟢 Bullish' : avg < -0.2 ? '🔴 Bearish' : '🟡 Mixed';
|
|
4398
|
+
const out = [`News sentiment — ${ticker || query} (${scored.length} headlines)`,
|
|
4399
|
+
`Aggregate: ${verdict} (score ${avg.toFixed(2)})`,
|
|
4400
|
+
`Distribution: ${counts.positive || 0} positive · ${counts.neutral || 0} neutral · ${counts.negative || 0} negative`,
|
|
4401
|
+
'',
|
|
4402
|
+
'Top signals:'];
|
|
4403
|
+
scored.slice(0, 5).forEach(s => out.push(` [${s.s}] ${headlines[s.i - 1] || '?'} — ${s.why}`));
|
|
4404
|
+
return out.join('\n');
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
case 'backtest_strategy': {
|
|
4408
|
+
// Thin wrapper around execute_code: produces a parametric backtest
|
|
4409
|
+
// script (pandas + numpy) and runs it. Useful as a one-liner from the
|
|
4410
|
+
// chat — for production-grade backtesting users should call execute_code
|
|
4411
|
+
// directly with their own strategy.
|
|
4412
|
+
const ticker = String(params.ticker || 'SPY').toUpperCase();
|
|
4413
|
+
const period = params.period || '5y';
|
|
4414
|
+
const strategy = params.strategy || 'sma_crossover'; // sma_crossover | rsi_meanrev | buy_hold
|
|
4415
|
+
const code = `
|
|
4416
|
+
import urllib.request, json, sys
|
|
4417
|
+
url = "https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?range=${period}&interval=1d"
|
|
4418
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
4419
|
+
r = urllib.request.urlopen(req).read()
|
|
4420
|
+
d = json.loads(r)
|
|
4421
|
+
res = d["chart"]["result"][0]
|
|
4422
|
+
closes = [c for c in res["indicators"]["quote"][0]["close"] if c is not None]
|
|
4423
|
+
strategy = "${strategy}"
|
|
4424
|
+
signal = [0]*len(closes)
|
|
4425
|
+
if strategy == "sma_crossover":
|
|
4426
|
+
fast, slow = 20, 50
|
|
4427
|
+
for i in range(slow, len(closes)):
|
|
4428
|
+
f = sum(closes[i-fast+1:i+1])/fast
|
|
4429
|
+
s = sum(closes[i-slow+1:i+1])/slow
|
|
4430
|
+
signal[i] = 1 if f > s else 0
|
|
4431
|
+
elif strategy == "rsi_meanrev":
|
|
4432
|
+
gains = [max(0, closes[i]-closes[i-1]) for i in range(1, len(closes))]
|
|
4433
|
+
losses = [max(0, closes[i-1]-closes[i]) for i in range(1, len(closes))]
|
|
4434
|
+
p = 14
|
|
4435
|
+
for i in range(p, len(closes)-1):
|
|
4436
|
+
avg_g = sum(gains[i-p:i])/p; avg_l = sum(losses[i-p:i])/p
|
|
4437
|
+
rsi = 100 - (100 / (1 + (avg_g / (avg_l or 1e-9))))
|
|
4438
|
+
signal[i+1] = 1 if rsi < 30 else 0 if rsi > 70 else signal[i]
|
|
4439
|
+
else:
|
|
4440
|
+
signal = [1]*len(closes) # buy & hold
|
|
4441
|
+
|
|
4442
|
+
rets, eq = [], [1.0]
|
|
4443
|
+
for i in range(1, len(closes)):
|
|
4444
|
+
r = signal[i-1] * (closes[i]-closes[i-1]) / closes[i-1]
|
|
4445
|
+
rets.append(r); eq.append(eq[-1] * (1+r))
|
|
4446
|
+
total = (eq[-1] - 1) * 100
|
|
4447
|
+
days = len(rets)
|
|
4448
|
+
ann = ((eq[-1]) ** (252/days) - 1) * 100 if days else 0
|
|
4449
|
+
import statistics as st
|
|
4450
|
+
vol = (st.pstdev(rets) * (252**0.5) * 100) if len(rets) > 1 else 0
|
|
4451
|
+
sharpe = (ann - 4) / vol if vol else 0
|
|
4452
|
+
peak = max_dd = 0
|
|
4453
|
+
for v in eq:
|
|
4454
|
+
peak = max(peak, v); max_dd = min(max_dd, (v-peak)/peak)
|
|
4455
|
+
print(f"Backtest {strategy} on ${ticker} (${period}):")
|
|
4456
|
+
print(f" Total return: {total:.2f}% over {days} days")
|
|
4457
|
+
print(f" Annualized: {ann:.2f}%, vol {vol:.2f}%, Sharpe {sharpe:.2f}")
|
|
4458
|
+
print(f" Max drawdown: {max_dd*100:.2f}%")
|
|
4459
|
+
`;
|
|
4460
|
+
return executeTool('execute_code', { language: 'python', code, timeout: 60 }, config);
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
case 'italian_market': {
|
|
4464
|
+
// FTSE MIB constituents and BTP-Bund spread (10y Italy minus 10y Germany).
|
|
4465
|
+
const which = String(params.what || 'all').toLowerCase();
|
|
4466
|
+
const lines = [];
|
|
4467
|
+
try {
|
|
4468
|
+
if (which === 'all' || which === 'mib' || which === 'index') {
|
|
4469
|
+
const r = await fetch('https://query1.finance.yahoo.com/v8/finance/chart/FTSEMIB.MI?range=5d&interval=1d', { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4470
|
+
if (r.ok) {
|
|
4471
|
+
const d = await r.json();
|
|
4472
|
+
const res = d.chart.result[0];
|
|
4473
|
+
const closes = res.indicators.quote[0].close.filter(Boolean);
|
|
4474
|
+
const last = closes[closes.length - 1];
|
|
4475
|
+
const prev = closes[closes.length - 2];
|
|
4476
|
+
const chg = prev ? ((last - prev) / prev * 100) : 0;
|
|
4477
|
+
lines.push(`FTSE MIB: ${last?.toFixed(0)} (${chg >= 0 ? '+' : ''}${chg.toFixed(2)}% giorn.)`);
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
if (which === 'all' || which === 'spread' || which === 'btp') {
|
|
4481
|
+
const [it, de] = await Promise.all([
|
|
4482
|
+
fetch('https://query1.finance.yahoo.com/v8/finance/chart/^TNX-IT?range=1d&interval=1d', { headers: { 'User-Agent': 'Mozilla/5.0' } }).catch(() => null),
|
|
4483
|
+
fetch('https://query1.finance.yahoo.com/v8/finance/chart/^TNX-DE?range=1d&interval=1d', { headers: { 'User-Agent': 'Mozilla/5.0' } }).catch(() => null),
|
|
4484
|
+
]);
|
|
4485
|
+
// Yahoo doesn't expose BTP/Bund directly via simple ticker; use approx via futures or skip.
|
|
4486
|
+
lines.push('BTP-Bund spread: dato non disponibile via Yahoo gratis. Suggerisco fetch_url su https://www.borsaitaliana.it/borsa/obbligazioni.html');
|
|
4487
|
+
}
|
|
4488
|
+
// Top constituents — manual list of FTSE MIB blue chips, fetched as batch
|
|
4489
|
+
if (which === 'all' || which === 'constituents' || which === 'top') {
|
|
4490
|
+
const tickers = ['ENI.MI','ENEL.MI','UCG.MI','ISP.MI','STLAM.MI','RACE.MI','TIT.MI','G.MI','MB.MI','LDO.MI','PRY.MI','SPM.MI','PST.MI','BAMI.MI','FBK.MI'];
|
|
4491
|
+
const r = await fetch(`https://query1.finance.yahoo.com/v7/finance/quote?symbols=${tickers.join(',')}`, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NHA/1.0)' } });
|
|
4492
|
+
if (r.ok) {
|
|
4493
|
+
const d = await r.json();
|
|
4494
|
+
const quotes = d?.quoteResponse?.result || [];
|
|
4495
|
+
lines.push('\nFTSE MIB — Top constituents:');
|
|
4496
|
+
quotes.forEach(q => {
|
|
4497
|
+
const chg = q.regularMarketChangePercent;
|
|
4498
|
+
lines.push(` ${q.symbol.padEnd(9)} ${(q.shortName || '').slice(0, 22).padEnd(23)} €${(q.regularMarketPrice || 0).toFixed(2).padStart(8)} ${(chg >= 0 ? '+' : '') + chg?.toFixed(2)}%`);
|
|
4499
|
+
});
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
if (lines.length === 0) return `italian_market: parametro "what" non riconosciuto. Usa "all" | "mib" | "constituents" | "spread".`;
|
|
4503
|
+
return lines.join('\n');
|
|
4504
|
+
} catch (e) { return `italian_market error: ${e.message}`; }
|
|
4505
|
+
}
|
|
4506
|
+
|
|
3948
4507
|
default:
|
|
3949
4508
|
return `Unknown action: ${action}`;
|
|
3950
4509
|
}
|