nothumanallowed 15.1.66 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "15.1.66",
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": {
@@ -97,8 +97,18 @@ export async function cmdOps(args) {
97
97
  const telegramConfigured = !!config.responder?.telegram?.token;
98
98
  const discordConfigured = !!config.responder?.discord?.token;
99
99
  console.log(`\n ${BOLD}Message Responder${NC}\n`);
100
- console.log(` Telegram: ${responder.telegram ? G + 'active' + NC : telegramConfigured ? Y + 'configured (daemon restart needed)' + NC : D + 'not configured' + NC}`);
101
- console.log(` Discord: ${responder.discord ? G + 'active' + NC : discordConfigured ? Y + 'configured (daemon restart needed)' + NC : D + 'not configured' + NC}`);
100
+ // Surface the exact reason the responder didn't activate (missing
101
+ // LLM key on a paid provider, missing token, etc.) instead of the
102
+ // generic "daemon restart needed" — which is misleading because the
103
+ // daemon IS running, the responder just refused to spin up.
104
+ const reason = responder.reason || '';
105
+ let inactiveHintTel = 'configured but inactive (try: nha ops stop && nha ops start)';
106
+ if (reason.startsWith('missing_key:')) {
107
+ const p = reason.slice('missing_key:'.length);
108
+ inactiveHintTel = `configured but LLM key missing for provider "${p}" — fix with: nha config set provider nha (free Liara) OR nha config set ${p}-key YOUR_KEY`;
109
+ }
110
+ console.log(` Telegram: ${responder.telegram ? G + 'active' + NC : telegramConfigured ? Y + inactiveHintTel + NC : D + 'not configured' + NC}`);
111
+ console.log(` Discord: ${responder.discord ? G + 'active' + NC : discordConfigured ? Y + inactiveHintTel + NC : D + 'not configured' + NC}`);
102
112
  console.log(` Auto-route: ${config.responder?.autoRoute !== false ? G + 'keyword routing' + NC : D + 'CONDUCTOR only' + NC}`);
103
113
 
104
114
  console.log('');
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.66';
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
 
@@ -2749,12 +2749,19 @@ export function startResponder(config, log, wsBroadcast) {
2749
2749
  const hasAnyToken = config.responder?.telegram?.token || config.responder?.discord?.token;
2750
2750
  if (!hasAnyToken) {
2751
2751
  log('[Responder] No tokens configured — skipping');
2752
- return { telegram: false, discord: false };
2753
- }
2754
-
2755
- if (!config.llm?.apiKey) {
2756
- log('[Responder] No LLM API key cannot respond to messages');
2757
- return { telegram: false, discord: false };
2752
+ return { telegram: false, discord: false, reason: 'no_token' };
2753
+ }
2754
+
2755
+ // Liara (provider 'nha') is the default free tier — it does NOT require an
2756
+ // API key. Only refuse to start if the user explicitly picked a paid
2757
+ // provider (anthropic, openai, gemini, ...) and forgot to set the key.
2758
+ // Previously we rejected ALL providers including Liara, leaving the user
2759
+ // stuck on "configured (daemon restart needed)" with no actionable hint.
2760
+ const provider = (config.llm?.provider || 'nha').toLowerCase();
2761
+ const PAID_PROVIDERS = new Set(['anthropic', 'openai', 'gemini', 'deepseek', 'grok', 'mistral', 'cohere']);
2762
+ if (PAID_PROVIDERS.has(provider) && !config.llm?.apiKey) {
2763
+ log(`[Responder] Provider "${provider}" requires an API key — cannot respond. Run: nha config set provider nha (to switch to free Liara) OR nha config set ${provider}-key YOUR_KEY`);
2764
+ return { telegram: false, discord: false, reason: `missing_key:${provider}` };
2758
2765
  }
2759
2766
 
2760
2767
  const result = { telegram: false, discord: false };
@@ -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&region=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
  }