alphacouncil-agent 0.5.0

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.
Files changed (139) hide show
  1. package/.claude/agents/alphacouncil-bear_researcher.md +39 -0
  2. package/.claude/agents/alphacouncil-bull_researcher.md +40 -0
  3. package/.claude/agents/alphacouncil-earnings_deep_dive.md +49 -0
  4. package/.claude/agents/alphacouncil-forward_expectations.md +48 -0
  5. package/.claude/agents/alphacouncil-ib_event_analysis.md +40 -0
  6. package/.claude/agents/alphacouncil-insider_sec.md +39 -0
  7. package/.claude/agents/alphacouncil-macro_regime.md +31 -0
  8. package/.claude/agents/alphacouncil-market_data.md +37 -0
  9. package/.claude/agents/alphacouncil-market_narrative.md +45 -0
  10. package/.claude/agents/alphacouncil-news_industry_management.md +49 -0
  11. package/.claude/agents/alphacouncil-portfolio_manager.md +22 -0
  12. package/.claude/agents/alphacouncil-quant_factor.md +38 -0
  13. package/.claude/agents/alphacouncil-social_pulse.md +49 -0
  14. package/.claude/agents/alphacouncil-valuation_long_short.md +45 -0
  15. package/.claude-plugin/marketplace.json +30 -0
  16. package/.claude-plugin/plugin.json +28 -0
  17. package/.codex-plugin/plugin.json +32 -0
  18. package/.grok/agents/alphacouncil-bear_researcher.md +41 -0
  19. package/.grok/agents/alphacouncil-bull_researcher.md +42 -0
  20. package/.grok/agents/alphacouncil-earnings_deep_dive.md +50 -0
  21. package/.grok/agents/alphacouncil-forward_expectations.md +49 -0
  22. package/.grok/agents/alphacouncil-ib_event_analysis.md +41 -0
  23. package/.grok/agents/alphacouncil-insider_sec.md +40 -0
  24. package/.grok/agents/alphacouncil-macro_regime.md +32 -0
  25. package/.grok/agents/alphacouncil-market_data.md +38 -0
  26. package/.grok/agents/alphacouncil-market_narrative.md +46 -0
  27. package/.grok/agents/alphacouncil-news_industry_management.md +50 -0
  28. package/.grok/agents/alphacouncil-portfolio_manager.md +24 -0
  29. package/.grok/agents/alphacouncil-quant_factor.md +39 -0
  30. package/.grok/agents/alphacouncil-social_pulse.md +50 -0
  31. package/.grok/agents/alphacouncil-valuation_long_short.md +46 -0
  32. package/.grok/config.toml +4 -0
  33. package/.mcp.json +11 -0
  34. package/.opencode/agent/alphacouncil-bear_researcher.md +45 -0
  35. package/.opencode/agent/alphacouncil-bull_researcher.md +46 -0
  36. package/.opencode/agent/alphacouncil-earnings_deep_dive.md +54 -0
  37. package/.opencode/agent/alphacouncil-forward_expectations.md +53 -0
  38. package/.opencode/agent/alphacouncil-ib_event_analysis.md +45 -0
  39. package/.opencode/agent/alphacouncil-insider_sec.md +44 -0
  40. package/.opencode/agent/alphacouncil-macro_regime.md +36 -0
  41. package/.opencode/agent/alphacouncil-market_data.md +42 -0
  42. package/.opencode/agent/alphacouncil-market_narrative.md +50 -0
  43. package/.opencode/agent/alphacouncil-news_industry_management.md +54 -0
  44. package/.opencode/agent/alphacouncil-portfolio_manager.md +28 -0
  45. package/.opencode/agent/alphacouncil-quant_factor.md +43 -0
  46. package/.opencode/agent/alphacouncil-social_pulse.md +54 -0
  47. package/.opencode/agent/alphacouncil-valuation_long_short.md +50 -0
  48. package/AGENTS.md +96 -0
  49. package/CHANGELOG.md +112 -0
  50. package/CLAUDE.md +144 -0
  51. package/LICENSE +21 -0
  52. package/README.ja.md +282 -0
  53. package/README.md +361 -0
  54. package/README.zh-CN.md +282 -0
  55. package/data/industry-map.json +74 -0
  56. package/docs/attribution.md +99 -0
  57. package/docs/personas.md +59 -0
  58. package/docs/report-contract.md +110 -0
  59. package/mcp/lib/codex.mjs +152 -0
  60. package/mcp/lib/constants.mjs +139 -0
  61. package/mcp/lib/council-options.mjs +111 -0
  62. package/mcp/lib/errors.mjs +46 -0
  63. package/mcp/lib/feeds.mjs +140 -0
  64. package/mcp/lib/fsutil.mjs +69 -0
  65. package/mcp/lib/gates.mjs +208 -0
  66. package/mcp/lib/grounding.mjs +252 -0
  67. package/mcp/lib/headings.mjs +65 -0
  68. package/mcp/lib/industry.mjs +208 -0
  69. package/mcp/lib/lang.mjs +24 -0
  70. package/mcp/lib/macro.mjs +167 -0
  71. package/mcp/lib/markdown.mjs +337 -0
  72. package/mcp/lib/markets-kr-jp.mjs +180 -0
  73. package/mcp/lib/markets.mjs +261 -0
  74. package/mcp/lib/narrative.mjs +235 -0
  75. package/mcp/lib/options.mjs +216 -0
  76. package/mcp/lib/orchestrator.mjs +541 -0
  77. package/mcp/lib/output-modes.mjs +144 -0
  78. package/mcp/lib/packets.mjs +256 -0
  79. package/mcp/lib/personas/frontmatter.mjs +80 -0
  80. package/mcp/lib/personas/registry.mjs +215 -0
  81. package/mcp/lib/preflight.mjs +174 -0
  82. package/mcp/lib/prompts.mjs +151 -0
  83. package/mcp/lib/quotes.mjs +108 -0
  84. package/mcp/lib/rpc.mjs +737 -0
  85. package/mcp/lib/run-store.mjs +140 -0
  86. package/mcp/lib/screen.mjs +283 -0
  87. package/mcp/lib/sec.mjs +131 -0
  88. package/mcp/lib/social.mjs +261 -0
  89. package/mcp/lib/tables.mjs +150 -0
  90. package/mcp/lib/text.mjs +27 -0
  91. package/mcp/lib/weights.mjs +150 -0
  92. package/mcp/server.mjs +92 -0
  93. package/opencode.json +20 -0
  94. package/package.json +61 -0
  95. package/personas/_shared/_debate_base.md +46 -0
  96. package/personas/_shared/_evidence_base.md +56 -0
  97. package/personas/_shared/_master_base.md +77 -0
  98. package/personas/analysts/earnings_deep_dive.md +118 -0
  99. package/personas/analysts/forward_expectations.md +115 -0
  100. package/personas/analysts/ib_event_analysis.md +100 -0
  101. package/personas/analysts/insider_sec.md +98 -0
  102. package/personas/analysts/macro_regime.md +66 -0
  103. package/personas/analysts/market_data.md +94 -0
  104. package/personas/analysts/market_narrative.md +94 -0
  105. package/personas/analysts/news_industry_management.md +117 -0
  106. package/personas/analysts/quant_factor.md +97 -0
  107. package/personas/analysts/social_pulse.md +102 -0
  108. package/personas/analysts/valuation_long_short.md +109 -0
  109. package/personas/debate/bear_researcher.md +96 -0
  110. package/personas/debate/bull_researcher.md +98 -0
  111. package/personas/debate/portfolio_manager.md +64 -0
  112. package/personas/masters/masters-adversarial/burry.md +137 -0
  113. package/personas/masters/masters-adversarial/dalio.md +139 -0
  114. package/personas/masters/masters-adversarial/druckenmiller.md +144 -0
  115. package/personas/masters/masters-adversarial/short_seller.md +145 -0
  116. package/personas/masters/masters-adversarial/soros.md +137 -0
  117. package/personas/masters/masters-modern/aschenbrenner.md +147 -0
  118. package/personas/masters/masters-options/natenberg.md +168 -0
  119. package/personas/masters/masters-options/sinclair.md +166 -0
  120. package/personas/masters/masters-options/taleb.md +163 -0
  121. package/personas/masters/masters-quant/asness.md +142 -0
  122. package/personas/masters/masters-quant/simons.md +143 -0
  123. package/personas/masters/masters-quant/thorp.md +143 -0
  124. package/personas/masters/masters-value/buffett.md +123 -0
  125. package/personas/masters/masters-value/duan_yongping.md +142 -0
  126. package/personas/masters/masters-value/li_lu.md +146 -0
  127. package/personas/masters/masters-value/munger.md +117 -0
  128. package/personas/masters/masters-value-classic/fisher.md +140 -0
  129. package/personas/masters/masters-value-classic/graham.md +137 -0
  130. package/personas/masters/masters-value-classic/klarman.md +138 -0
  131. package/personas/masters/masters-value-classic/lynch.md +133 -0
  132. package/personas/masters/masters-value-classic/marks.md +145 -0
  133. package/personas/verifiers/rederivation.md +97 -0
  134. package/personas/verifiers/refuter.md +99 -0
  135. package/personas/verifiers/source_fidelity.md +91 -0
  136. package/skills/agent-skills-governance/SKILL.md +42 -0
  137. package/skills/alphacouncil-agent/SKILL.md +259 -0
  138. package/skills/investment-banking/SKILL.md +70 -0
  139. package/skills/public-equity-investing/SKILL.md +74 -0
@@ -0,0 +1,208 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { invalidParams } from "./errors.mjs";
4
+ import { fetchUniverse, fetchSubmissions } from "./sec.mjs";
5
+
6
+ /**
7
+ * Industry framing.
8
+ *
9
+ * A question like "how does memory look right now" cannot be answered by a pipeline whose
10
+ * only entry point is a single ticker, and it cannot be answered honestly by asking a
11
+ * model to list the participants either -- that produces the names it saw most often,
12
+ * silently dropping the ones that do not file with the SEC. SK hynix and Kioxia are
13
+ * exactly the companies such a list loses, and in memory they are most of the industry.
14
+ *
15
+ * So the participant list is data, hand-maintained and explicitly incomplete, and the map
16
+ * carries the two things a model reliably gets wrong on its own: which link in the chain
17
+ * currently binds, and who actually creates the orders.
18
+ */
19
+ const MAP_PATH = fileURLToPath(new URL("../../data/industry-map.json", import.meta.url));
20
+
21
+ let cached = null;
22
+ function load() {
23
+ if (!cached) cached = JSON.parse(readFileSync(MAP_PATH, "utf8"));
24
+ return cached;
25
+ }
26
+
27
+ export function listIndustries() {
28
+ const { industries } = load();
29
+ return Object.entries(industries).map(([id, entry]) => ({
30
+ id,
31
+ title: entry.title,
32
+ aliases: entry.aliases,
33
+ participant_count: entry.layers.reduce((n, l) => n + l.participants.length, 0),
34
+ }));
35
+ }
36
+
37
+ /** Resolve a free-text industry query against ids and aliases. */
38
+ export function resolveIndustry(query) {
39
+ const needle = String(query || "").trim().toLowerCase();
40
+ if (!needle) throw invalidParams("industry query is empty");
41
+ const { industries } = load();
42
+ for (const [id, entry] of Object.entries(industries)) {
43
+ if (id === needle) return { id, ...entry };
44
+ if (entry.aliases.some((a) => a.toLowerCase() === needle)) return { id, ...entry };
45
+ }
46
+ // Fall back to substring so "存储芯片行业" or "HBM memory" still lands.
47
+ for (const [id, entry] of Object.entries(industries)) {
48
+ if (entry.aliases.some((a) => needle.includes(a.toLowerCase()) || a.toLowerCase().includes(needle))) {
49
+ return { id, ...entry };
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Everything a research run needs to start on an industry rather than a ticker.
57
+ *
58
+ * Deliberately returns no verdict. It returns the frame: participants by chain position
59
+ * with their market and filing regime, who drives demand, the questions that must be
60
+ * answered, and how the industry behaves through a cycle -- because reading a cyclical's
61
+ * low P/E as cheap is the most expensive mistake available here.
62
+ */
63
+ export function industryBrief(query) {
64
+ const entry = resolveIndustry(query);
65
+ if (!entry) {
66
+ const known = listIndustries().map((i) => `${i.id} (${i.aliases.slice(0, 3).join(", ")})`);
67
+ throw invalidParams(`no industry map for "${query}". Mapped industries: ${known.join("; ") || "none"}. Add one in data/industry-map.json.`);
68
+ }
69
+
70
+ const participants = entry.layers.flatMap((layer) =>
71
+ layer.participants.map((p) => ({ ...p, layer: layer.layer })));
72
+
73
+ // Which names this pipeline can screen mechanically, and which it cannot. Saying so up
74
+ // front stops a run from quietly becoming US-only.
75
+ const secScreenable = participants.filter((p) => p.symbol && p.market.includes("US"));
76
+ const needsOtherSource = participants.filter((p) => p.symbol && !p.market.includes("US"));
77
+ const unlisted = participants.filter((p) => !p.symbol);
78
+
79
+ return {
80
+ id: entry.id,
81
+ title: entry.title,
82
+ layers: entry.layers,
83
+ participants,
84
+ demand_drivers: entry.demand_drivers,
85
+ key_questions: entry.key_questions,
86
+ cyclicality: entry.cyclicality,
87
+ coverage: {
88
+ sec_screenable: secScreenable.map((p) => p.symbol),
89
+ needs_local_regulator_feed: needsOtherSource.map((p) => ({ symbol: p.symbol, market: p.market })),
90
+ unlisted: unlisted.map((p) => p.name),
91
+ note:
92
+ "screen_ticker and the SEC concepts cover US filers only. Korean, Japanese and Taiwanese participants "
93
+ + "have to be researched through search and their own regulators (DART, EDINET, MOPS), and unlisted "
94
+ + "capacity still moves global supply. Treat any conclusion drawn only from the US-listed subset as "
95
+ + "partial, and say so.",
96
+ },
97
+ how_to_use:
98
+ "This is a frame, not an answer. Run the council on the participants that matter for the question asked, "
99
+ + "check the demand drivers' own disclosures rather than press coverage of them, and answer the key "
100
+ + "questions explicitly. Guidance is not an order: distinguish what a customer said it plans to spend "
101
+ + "from what has actually been ordered and at what lead time.",
102
+ };
103
+ }
104
+
105
+
106
+ /**
107
+ * SIC industry groups. This is the half that covers EVERY industry rather than the
108
+ * handful someone has written a map for.
109
+ *
110
+ * SEC assigns a SIC code to every filer, so grouping by it classifies the whole US
111
+ * universe with no curation and no model. What it cannot give is the part a curated map
112
+ * exists for: chain position, non-US participants, and who actually creates the orders.
113
+ * So the two are used together and the brief says which half it is standing on.
114
+ */
115
+
116
+ /** Broad SIC ranges, so a query like "banks" or "biotech" lands without an exact code. */
117
+ export const SIC_GROUPS = [
118
+ { id: "agriculture", range: [100, 999], title: { zh: "农业", en: "Agriculture" } },
119
+ { id: "mining_energy", range: [1000, 1499], title: { zh: "采矿与能源", en: "Mining and energy" } },
120
+ { id: "construction", range: [1500, 1799], title: { zh: "建筑", en: "Construction" } },
121
+ { id: "food_beverage", range: [2000, 2199], title: { zh: "食品饮料", en: "Food and beverage" } },
122
+ { id: "chemicals", range: [2800, 2899], title: { zh: "化工", en: "Chemicals" } },
123
+ { id: "pharma_biotech", range: [2833, 2836], title: { zh: "医药与生物科技", en: "Pharma and biotech" } },
124
+ { id: "energy_refining", range: [2900, 2999], title: { zh: "炼化", en: "Refining" } },
125
+ { id: "metals", range: [3300, 3399], title: { zh: "金属", en: "Metals" } },
126
+ { id: "machinery", range: [3500, 3569], title: { zh: "机械", en: "Machinery" } },
127
+ { id: "computers_hardware", range: [3570, 3579], title: { zh: "计算机硬件", en: "Computers and hardware" } },
128
+ { id: "electronics", range: [3600, 3673], title: { zh: "电子", en: "Electronics" } },
129
+ { id: "semiconductors", range: [3674, 3674], title: { zh: "半导体", en: "Semiconductors" } },
130
+ { id: "instruments_medical", range: [3820, 3873], title: { zh: "仪器与医疗器械", en: "Instruments and medical devices" } },
131
+ { id: "transportation", range: [4000, 4799], title: { zh: "运输", en: "Transportation" } },
132
+ { id: "telecom", range: [4800, 4899], title: { zh: "电信", en: "Telecom" } },
133
+ { id: "utilities", range: [4900, 4999], title: { zh: "公用事业", en: "Utilities" } },
134
+ { id: "retail", range: [5200, 5999], title: { zh: "零售", en: "Retail" } },
135
+ { id: "banks", range: [6020, 6199], title: { zh: "银行", en: "Banks" } },
136
+ { id: "insurance", range: [6300, 6411], title: { zh: "保险", en: "Insurance" } },
137
+ { id: "real_estate", range: [6500, 6799], title: { zh: "房地产", en: "Real estate" } },
138
+ { id: "software", range: [7370, 7379], title: { zh: "软件与IT服务", en: "Software and IT services" } },
139
+ { id: "healthcare_services", range: [8000, 8099], title: { zh: "医疗服务", en: "Healthcare services" } },
140
+ ];
141
+
142
+ export function sicGroupFor(sic) {
143
+ const code = Number(sic);
144
+ if (!Number.isFinite(code)) return null;
145
+ // Narrowest matching range wins, so 3674 is semiconductors rather than electronics.
146
+ const matches = SIC_GROUPS.filter((g) => code >= g.range[0] && code <= g.range[1]);
147
+ if (!matches.length) return null;
148
+ return matches.sort((a, b) => (a.range[1] - a.range[0]) - (b.range[1] - b.range[0]))[0];
149
+ }
150
+
151
+ let universeCache = null;
152
+
153
+ /**
154
+ * Every US filer whose SIC matches a query, with no curation involved.
155
+ *
156
+ * Resolving each company's SIC needs one request per company, which is far too many for
157
+ * a 10k universe, so this works from a caller-supplied candidate list or from a name
158
+ * match. It is a starting universe, not a definitive index membership list, and says so.
159
+ */
160
+ export async function peersBySic({ cik, limit = 25 } = {}) {
161
+ if (!cik) throw invalidParams("peersBySic needs a cik to anchor on");
162
+ const anchor = await fetchSubmissions(cik);
163
+ if (!anchor.sic) {
164
+ return { anchor, group: null, peers: [], note: "SEC has no SIC classification for this filer." };
165
+ }
166
+ const group = sicGroupFor(anchor.sic);
167
+
168
+ if (!universeCache) universeCache = await fetchUniverse();
169
+ // Company names are the only universe-wide signal available without 10k requests.
170
+ const words = anchor.name.toLowerCase().split(/\s+/).filter((w) => w.length > 4 && !/corp|inc|company|holdings|group|technologies/.test(w));
171
+ const nameMatched = words.length
172
+ ? universeCache.filter((c) => c.cik !== anchor.cik && words.some((w) => c.title.toLowerCase().includes(w)))
173
+ : [];
174
+
175
+ return {
176
+ anchor,
177
+ group: group ? { id: group.id, title: group.title, sic_range: group.range } : null,
178
+ sic: anchor.sic,
179
+ sic_description: anchor.sic_description,
180
+ peers: nameMatched.slice(0, limit),
181
+ note:
182
+ `SIC ${anchor.sic} (${anchor.sic_description}) classifies this filer. SIC covers every US filer, so it `
183
+ + "reaches industries no curated map has, but it says nothing about position in a value chain, about "
184
+ + "non-US participants, or about who creates the demand. Where industry_brief has a curated map, prefer it "
185
+ + "and use this only to widen the candidate list. Peer matching here is by company name and is a starting "
186
+ + "point, not an index membership list.",
187
+ };
188
+ }
189
+
190
+ /** Which half of the industry story is available for a query. */
191
+ export function industryCoverage(query) {
192
+ const curated = resolveIndustry(query);
193
+ const needle = String(query || "").toLowerCase();
194
+ const sicGroup = SIC_GROUPS.find((g) =>
195
+ g.id.includes(needle) || needle.includes(g.id) || g.title.en.toLowerCase().includes(needle) || g.title.zh.includes(query));
196
+ return {
197
+ query,
198
+ curated: curated ? { id: curated.id, title: curated.title } : null,
199
+ sic_group: sicGroup ? { id: sicGroup.id, title: sicGroup.title, sic_range: sicGroup.range } : null,
200
+ guidance: curated
201
+ ? "A curated map exists: use industry_brief for the chain, the non-US participants and the demand drivers."
202
+ : sicGroup
203
+ ? "No curated map, but SIC classifies this. Use screen_ticker on candidates and research the chain and "
204
+ + "non-US participants through search -- and say in the report that the participant list is not curated."
205
+ : "Neither a curated map nor a SIC group matched. Treat the participant list as research output, state that "
206
+ + "it is not authoritative, and do not present it as complete.",
207
+ };
208
+ }
@@ -0,0 +1,24 @@
1
+ export function normalizeLanguage(value) {
2
+ const text = String(value || "").trim();
3
+ if (!text || /^auto|default|same|follow|跟随|默认$/i.test(text)) return "";
4
+ if (/^(zh|zh-cn|cn|chinese|中文|简体中文|繁体中文)$/i.test(text)) return "中文";
5
+ if (/^(en|en-us|english|英文)$/i.test(text)) return "English";
6
+ if (/^(ja|jp|ja-jp|japanese|日文|日本語)$/i.test(text)) return "日本語";
7
+ if (/^(ko|kr|korean|韩文|韓文|한국어)$/i.test(text)) return "한국어";
8
+ return text.slice(0, 40);
9
+ }
10
+
11
+ export function inferLanguage(text = "") {
12
+ if (/[\u3040-\u30ff]/.test(text)) return "日本語";
13
+ if (/[\uac00-\ud7af]/.test(text)) return "한국어";
14
+ if (/[\u3400-\u9fff]/.test(text)) return "中文";
15
+ return "English";
16
+ }
17
+
18
+ export function resolveLanguage(args = {}) {
19
+ return normalizeLanguage(args.language || args.output_language || args.user_language) || inferLanguage(args.prompt || args.user_prompt || "");
20
+ }
21
+
22
+ export function isChineseLanguage(language) {
23
+ return /中文|chinese|zh/i.test(String(language || ""));
24
+ }
@@ -0,0 +1,167 @@
1
+ import { fetchQuote } from "./quotes.mjs";
2
+ import { invalidParams } from "./errors.mjs";
3
+
4
+ /**
5
+ * Top-down macro context, built entirely from the keyless quote pipeline.
6
+ *
7
+ * Everything here is a market-priced series, so the snapshot is a set of observations
8
+ * rather than a forecast. No API key, no vendor account: the same Yahoo/Stooq path
9
+ * get_quote already uses.
10
+ *
11
+ * Deliberately NOT included: any single number claiming to say what "the regime" is.
12
+ * The blocks give the inputs; the macro_regime persona has to argue the reading and say
13
+ * what it means for the specific name under research.
14
+ */
15
+ export const MACRO_BLOCKS = [
16
+ {
17
+ id: "rates",
18
+ title: { zh: "利率曲线", en: "Rate curve" },
19
+ why: {
20
+ zh: "曲线形状决定了折现率和银行的放贷意愿;倒挂是最被过度解读也最不该忽略的一个信号。",
21
+ en: "The curve sets the discount rate and the willingness of banks to lend. Inversion is the most over-read and least ignorable signal on this list.",
22
+ },
23
+ members: [
24
+ { symbol: "^IRX", label: "US 3M" },
25
+ { symbol: "^FVX", label: "US 5Y" },
26
+ { symbol: "^TNX", label: "US 10Y" },
27
+ { symbol: "^TYX", label: "US 30Y" },
28
+ ],
29
+ },
30
+ {
31
+ id: "dollar_liquidity",
32
+ title: { zh: "美元与流动性", en: "Dollar and liquidity" },
33
+ why: {
34
+ zh: "美元走强会同时压制非美盈利和大宗;信用利差决定弱资产负债表的公司能不能续命。",
35
+ en: "A stronger dollar compresses non-US earnings and commodities at once, and credit spreads decide whether weak balance sheets can refinance.",
36
+ },
37
+ members: [
38
+ { symbol: "DX-Y.NYB", label: "Dollar index" },
39
+ { symbol: "^VIX", label: "VIX" },
40
+ { symbol: "HYG", label: "High yield" },
41
+ { symbol: "LQD", label: "Investment grade" },
42
+ ],
43
+ },
44
+ {
45
+ id: "commodities",
46
+ title: { zh: "商品", en: "Commodities" },
47
+ why: {
48
+ zh: "铜金比是最便宜的增长代理;油价同时是成本项和通胀项。",
49
+ en: "The copper-gold ratio is the cheapest available growth proxy, and oil is simultaneously a cost line and an inflation input.",
50
+ },
51
+ members: [
52
+ { symbol: "CL=F", label: "Crude" },
53
+ { symbol: "GC=F", label: "Gold" },
54
+ { symbol: "HG=F", label: "Copper" },
55
+ ],
56
+ },
57
+ {
58
+ id: "risk_appetite",
59
+ title: { zh: "风险偏好与市场宽度", en: "Risk appetite and breadth" },
60
+ why: {
61
+ zh: "等权重相对市值加权的表现,揭示涨势是普遍的还是被少数权重股撑起来的。",
62
+ en: "Equal weight against cap weight reveals whether a rally is broad or is being carried by a handful of index heavyweights.",
63
+ },
64
+ members: [
65
+ { symbol: "^GSPC", label: "S&P 500" },
66
+ { symbol: "^NDX", label: "Nasdaq 100" },
67
+ { symbol: "RSP", label: "S&P equal weight" },
68
+ { symbol: "SPY", label: "S&P cap weight" },
69
+ { symbol: "^SOX", label: "Semiconductors" },
70
+ ],
71
+ },
72
+ {
73
+ id: "cross_market",
74
+ title: { zh: "跨市场", en: "Cross market" },
75
+ why: {
76
+ zh: "非美市场常常先于美股反映同一个宏观变化,尤其对出口链和供应链敏感的标的。",
77
+ en: "Non-US markets often price the same macro change first, especially for export and supply-chain exposed names.",
78
+ },
79
+ members: [
80
+ { symbol: "^N225", label: "Japan" },
81
+ { symbol: "^HSI", label: "Hong Kong" },
82
+ { symbol: "000001.SS", label: "China A" },
83
+ { symbol: "^KS11", label: "Korea" },
84
+ { symbol: "^TWII", label: "Taiwan" },
85
+ ],
86
+ },
87
+ ];
88
+
89
+ const price = (quotes, symbol) => {
90
+ const quote = quotes.get(symbol);
91
+ return quote && !quote.error && Number.isFinite(quote.price) ? quote.price : null;
92
+ };
93
+
94
+ /**
95
+ * Ratios and spreads that are only meaningful as a pair. Each returns null rather than a
96
+ * guess when either leg is unavailable, so a data gap stays visible as a data gap.
97
+ */
98
+ function derived(quotes) {
99
+ const out = [];
100
+ const push = (id, label, value, note) => out.push({ id, label, value, available: value !== null, note });
101
+
102
+ const y3m = price(quotes, "^IRX");
103
+ const y10 = price(quotes, "^TNX");
104
+ const y5 = price(quotes, "^FVX");
105
+ const y30 = price(quotes, "^TYX");
106
+
107
+ push("spread_10y_3m", { en: "10Y minus 3M", zh: "10年期减3个月" }, y10 !== null && y3m !== null ? Number((y10 - y3m).toFixed(3)) : null,
108
+ "Negative is an inverted curve. The most-watched recession proxy, and the one most often read too early.");
109
+ push("spread_30y_5y", { en: "30Y minus 5Y", zh: "30年期减5年期" }, y30 !== null && y5 !== null ? Number((y30 - y5).toFixed(3)) : null,
110
+ "Steepening at the long end usually prices term premium or inflation risk rather than growth.");
111
+
112
+ const copper = price(quotes, "HG=F");
113
+ const gold = price(quotes, "GC=F");
114
+ push("copper_gold", { en: "Copper / gold", zh: "铜金比" }, copper !== null && gold !== null ? Number((copper / gold).toFixed(6)) : null,
115
+ "Rising is a growth signal, falling is a flight to safety. A cheap real-time growth proxy.");
116
+
117
+ const hyg = price(quotes, "HYG");
118
+ const lqd = price(quotes, "LQD");
119
+ push("hyg_lqd", { en: "High yield / investment grade", zh: "高收益/投资级" }, hyg !== null && lqd !== null ? Number((hyg / lqd).toFixed(4)) : null,
120
+ "Falling means credit is repricing risk; weak balance sheets feel this before equity does.");
121
+
122
+ const rsp = price(quotes, "RSP");
123
+ const spy = price(quotes, "SPY");
124
+ push("breadth_rsp_spy", { en: "Equal weight / cap weight", zh: "等权重/市值加权" }, rsp !== null && spy !== null ? Number((rsp / spy).toFixed(4)) : null,
125
+ "Falling means the index is being carried by its largest members rather than by broad participation.");
126
+
127
+ return out;
128
+ }
129
+
130
+ export async function getMacroSnapshot(args = {}) {
131
+ const requested = Array.isArray(args?.blocks) && args.blocks.length ? args.blocks : MACRO_BLOCKS.map((b) => b.id);
132
+ const unknown = requested.filter((id) => !MACRO_BLOCKS.some((b) => b.id === id));
133
+ if (unknown.length) {
134
+ throw invalidParams(`unknown macro block(s): ${unknown.join(", ")}. Available: ${MACRO_BLOCKS.map((b) => b.id).join(", ")}`);
135
+ }
136
+ const blocks = MACRO_BLOCKS.filter((block) => requested.includes(block.id));
137
+ const symbols = [...new Set(blocks.flatMap((block) => block.members.map((m) => m.symbol)))];
138
+
139
+ const results = await Promise.all(symbols.map((symbol) => fetchQuote(symbol).catch((error) => ({
140
+ query: symbol,
141
+ error: String(error?.message || error),
142
+ }))));
143
+ const quotes = new Map(symbols.map((symbol, index) => [symbol, results[index]]));
144
+
145
+ const unavailable = [];
146
+ const rendered = blocks.map((block) => ({
147
+ id: block.id,
148
+ title: block.title,
149
+ why_it_matters: block.why,
150
+ members: block.members.map((member) => {
151
+ const quote = quotes.get(member.symbol);
152
+ if (!quote || quote.error) unavailable.push(`${member.label} (${member.symbol})`);
153
+ return { ...member, quote };
154
+ }),
155
+ }));
156
+
157
+ return {
158
+ as_of: new Date().toISOString(),
159
+ blocks: rendered,
160
+ derived: derived(quotes),
161
+ unavailable,
162
+ disclaimer:
163
+ "Keyless delayed market data (Yahoo/Stooq, ~15m or EOD). These are observations, not a regime call: "
164
+ + "read them, do not report them. Anything in `unavailable` is a data gap and belongs in open_questions, "
165
+ + "never a number filled in from memory.",
166
+ };
167
+ }