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.
- package/.claude/agents/alphacouncil-bear_researcher.md +39 -0
- package/.claude/agents/alphacouncil-bull_researcher.md +40 -0
- package/.claude/agents/alphacouncil-earnings_deep_dive.md +49 -0
- package/.claude/agents/alphacouncil-forward_expectations.md +48 -0
- package/.claude/agents/alphacouncil-ib_event_analysis.md +40 -0
- package/.claude/agents/alphacouncil-insider_sec.md +39 -0
- package/.claude/agents/alphacouncil-macro_regime.md +31 -0
- package/.claude/agents/alphacouncil-market_data.md +37 -0
- package/.claude/agents/alphacouncil-market_narrative.md +45 -0
- package/.claude/agents/alphacouncil-news_industry_management.md +49 -0
- package/.claude/agents/alphacouncil-portfolio_manager.md +22 -0
- package/.claude/agents/alphacouncil-quant_factor.md +38 -0
- package/.claude/agents/alphacouncil-social_pulse.md +49 -0
- package/.claude/agents/alphacouncil-valuation_long_short.md +45 -0
- package/.claude-plugin/marketplace.json +30 -0
- package/.claude-plugin/plugin.json +28 -0
- package/.codex-plugin/plugin.json +32 -0
- package/.grok/agents/alphacouncil-bear_researcher.md +41 -0
- package/.grok/agents/alphacouncil-bull_researcher.md +42 -0
- package/.grok/agents/alphacouncil-earnings_deep_dive.md +50 -0
- package/.grok/agents/alphacouncil-forward_expectations.md +49 -0
- package/.grok/agents/alphacouncil-ib_event_analysis.md +41 -0
- package/.grok/agents/alphacouncil-insider_sec.md +40 -0
- package/.grok/agents/alphacouncil-macro_regime.md +32 -0
- package/.grok/agents/alphacouncil-market_data.md +38 -0
- package/.grok/agents/alphacouncil-market_narrative.md +46 -0
- package/.grok/agents/alphacouncil-news_industry_management.md +50 -0
- package/.grok/agents/alphacouncil-portfolio_manager.md +24 -0
- package/.grok/agents/alphacouncil-quant_factor.md +39 -0
- package/.grok/agents/alphacouncil-social_pulse.md +50 -0
- package/.grok/agents/alphacouncil-valuation_long_short.md +46 -0
- package/.grok/config.toml +4 -0
- package/.mcp.json +11 -0
- package/.opencode/agent/alphacouncil-bear_researcher.md +45 -0
- package/.opencode/agent/alphacouncil-bull_researcher.md +46 -0
- package/.opencode/agent/alphacouncil-earnings_deep_dive.md +54 -0
- package/.opencode/agent/alphacouncil-forward_expectations.md +53 -0
- package/.opencode/agent/alphacouncil-ib_event_analysis.md +45 -0
- package/.opencode/agent/alphacouncil-insider_sec.md +44 -0
- package/.opencode/agent/alphacouncil-macro_regime.md +36 -0
- package/.opencode/agent/alphacouncil-market_data.md +42 -0
- package/.opencode/agent/alphacouncil-market_narrative.md +50 -0
- package/.opencode/agent/alphacouncil-news_industry_management.md +54 -0
- package/.opencode/agent/alphacouncil-portfolio_manager.md +28 -0
- package/.opencode/agent/alphacouncil-quant_factor.md +43 -0
- package/.opencode/agent/alphacouncil-social_pulse.md +54 -0
- package/.opencode/agent/alphacouncil-valuation_long_short.md +50 -0
- package/AGENTS.md +96 -0
- package/CHANGELOG.md +112 -0
- package/CLAUDE.md +144 -0
- package/LICENSE +21 -0
- package/README.ja.md +282 -0
- package/README.md +361 -0
- package/README.zh-CN.md +282 -0
- package/data/industry-map.json +74 -0
- package/docs/attribution.md +99 -0
- package/docs/personas.md +59 -0
- package/docs/report-contract.md +110 -0
- package/mcp/lib/codex.mjs +152 -0
- package/mcp/lib/constants.mjs +139 -0
- package/mcp/lib/council-options.mjs +111 -0
- package/mcp/lib/errors.mjs +46 -0
- package/mcp/lib/feeds.mjs +140 -0
- package/mcp/lib/fsutil.mjs +69 -0
- package/mcp/lib/gates.mjs +208 -0
- package/mcp/lib/grounding.mjs +252 -0
- package/mcp/lib/headings.mjs +65 -0
- package/mcp/lib/industry.mjs +208 -0
- package/mcp/lib/lang.mjs +24 -0
- package/mcp/lib/macro.mjs +167 -0
- package/mcp/lib/markdown.mjs +337 -0
- package/mcp/lib/markets-kr-jp.mjs +180 -0
- package/mcp/lib/markets.mjs +261 -0
- package/mcp/lib/narrative.mjs +235 -0
- package/mcp/lib/options.mjs +216 -0
- package/mcp/lib/orchestrator.mjs +541 -0
- package/mcp/lib/output-modes.mjs +144 -0
- package/mcp/lib/packets.mjs +256 -0
- package/mcp/lib/personas/frontmatter.mjs +80 -0
- package/mcp/lib/personas/registry.mjs +215 -0
- package/mcp/lib/preflight.mjs +174 -0
- package/mcp/lib/prompts.mjs +151 -0
- package/mcp/lib/quotes.mjs +108 -0
- package/mcp/lib/rpc.mjs +737 -0
- package/mcp/lib/run-store.mjs +140 -0
- package/mcp/lib/screen.mjs +283 -0
- package/mcp/lib/sec.mjs +131 -0
- package/mcp/lib/social.mjs +261 -0
- package/mcp/lib/tables.mjs +150 -0
- package/mcp/lib/text.mjs +27 -0
- package/mcp/lib/weights.mjs +150 -0
- package/mcp/server.mjs +92 -0
- package/opencode.json +20 -0
- package/package.json +61 -0
- package/personas/_shared/_debate_base.md +46 -0
- package/personas/_shared/_evidence_base.md +56 -0
- package/personas/_shared/_master_base.md +77 -0
- package/personas/analysts/earnings_deep_dive.md +118 -0
- package/personas/analysts/forward_expectations.md +115 -0
- package/personas/analysts/ib_event_analysis.md +100 -0
- package/personas/analysts/insider_sec.md +98 -0
- package/personas/analysts/macro_regime.md +66 -0
- package/personas/analysts/market_data.md +94 -0
- package/personas/analysts/market_narrative.md +94 -0
- package/personas/analysts/news_industry_management.md +117 -0
- package/personas/analysts/quant_factor.md +97 -0
- package/personas/analysts/social_pulse.md +102 -0
- package/personas/analysts/valuation_long_short.md +109 -0
- package/personas/debate/bear_researcher.md +96 -0
- package/personas/debate/bull_researcher.md +98 -0
- package/personas/debate/portfolio_manager.md +64 -0
- package/personas/masters/masters-adversarial/burry.md +137 -0
- package/personas/masters/masters-adversarial/dalio.md +139 -0
- package/personas/masters/masters-adversarial/druckenmiller.md +144 -0
- package/personas/masters/masters-adversarial/short_seller.md +145 -0
- package/personas/masters/masters-adversarial/soros.md +137 -0
- package/personas/masters/masters-modern/aschenbrenner.md +147 -0
- package/personas/masters/masters-options/natenberg.md +168 -0
- package/personas/masters/masters-options/sinclair.md +166 -0
- package/personas/masters/masters-options/taleb.md +163 -0
- package/personas/masters/masters-quant/asness.md +142 -0
- package/personas/masters/masters-quant/simons.md +143 -0
- package/personas/masters/masters-quant/thorp.md +143 -0
- package/personas/masters/masters-value/buffett.md +123 -0
- package/personas/masters/masters-value/duan_yongping.md +142 -0
- package/personas/masters/masters-value/li_lu.md +146 -0
- package/personas/masters/masters-value/munger.md +117 -0
- package/personas/masters/masters-value-classic/fisher.md +140 -0
- package/personas/masters/masters-value-classic/graham.md +137 -0
- package/personas/masters/masters-value-classic/klarman.md +138 -0
- package/personas/masters/masters-value-classic/lynch.md +133 -0
- package/personas/masters/masters-value-classic/marks.md +145 -0
- package/personas/verifiers/rederivation.md +97 -0
- package/personas/verifiers/refuter.md +99 -0
- package/personas/verifiers/source_fidelity.md +91 -0
- package/skills/agent-skills-governance/SKILL.md +42 -0
- package/skills/alphacouncil-agent/SKILL.md +259 -0
- package/skills/investment-banking/SKILL.md +70 -0
- package/skills/public-equity-investing/SKILL.md +74 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { LIMITS } from "./constants.mjs";
|
|
2
|
+
import { invalidParams } from "./errors.mjs";
|
|
3
|
+
import { fetchQuote } from "./quotes.mjs";
|
|
4
|
+
import { fetchDartFinancials, fetchEdinetFilings } from "./markets-kr-jp.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where structured financials come from, per market, and what to do when they do not.
|
|
8
|
+
*
|
|
9
|
+
* The pipeline was US-only in everything but name: an industry map could list SK hynix
|
|
10
|
+
* and Kioxia, and then nothing could fetch a number for them. This declares the real
|
|
11
|
+
* capability of each market and degrades in a stated order rather than silently
|
|
12
|
+
* producing a US-only answer dressed as a global one.
|
|
13
|
+
*
|
|
14
|
+
* Tiers, in the order they are tried:
|
|
15
|
+
* 1. keyless regulator feed -- SEC (US), TWSE (TW)
|
|
16
|
+
* 2. regulator feed needing a free key -- DART (KR), EDINET (JP); reported as
|
|
17
|
+
* not_configured rather than pretended away
|
|
18
|
+
* 3. quotes, which work everywhere, plus search -- always labelled as such, because a
|
|
19
|
+
* price is not a financial statement
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const MARKETS = {
|
|
23
|
+
US: {
|
|
24
|
+
id: "US",
|
|
25
|
+
suffixes: [""],
|
|
26
|
+
regulator: "SEC EDGAR",
|
|
27
|
+
tier: "keyless",
|
|
28
|
+
capability: "full",
|
|
29
|
+
note: "companyfacts XBRL: full statements with filing dates.",
|
|
30
|
+
},
|
|
31
|
+
TW: {
|
|
32
|
+
id: "TW",
|
|
33
|
+
suffixes: [".TW", ".TWO"],
|
|
34
|
+
regulator: "TWSE OpenAPI",
|
|
35
|
+
tier: "keyless",
|
|
36
|
+
capability: "summary",
|
|
37
|
+
note: "quarterly income-statement summary for listed companies; no full XBRL history.",
|
|
38
|
+
},
|
|
39
|
+
KR: {
|
|
40
|
+
id: "KR",
|
|
41
|
+
suffixes: [".KS", ".KQ"],
|
|
42
|
+
regulator: "DART (opendart.fss.or.kr)",
|
|
43
|
+
tier: "free_key",
|
|
44
|
+
env: "ALPHACOUNCIL_DART_KEY",
|
|
45
|
+
capability: "full",
|
|
46
|
+
note: "full filings, but requires a free API key. Register at opendart.fss.or.kr.",
|
|
47
|
+
},
|
|
48
|
+
JP: {
|
|
49
|
+
id: "JP",
|
|
50
|
+
suffixes: [".T"],
|
|
51
|
+
regulator: "EDINET v2 (api.edinet-fsa.go.jp)",
|
|
52
|
+
tier: "free_key",
|
|
53
|
+
env: "ALPHACOUNCIL_EDINET_KEY",
|
|
54
|
+
capability: "full",
|
|
55
|
+
note: "full filings, but requires a free subscription key from the EDINET portal.",
|
|
56
|
+
},
|
|
57
|
+
HK: {
|
|
58
|
+
id: "HK",
|
|
59
|
+
suffixes: [".HK"],
|
|
60
|
+
regulator: "HKEXnews",
|
|
61
|
+
tier: "none",
|
|
62
|
+
capability: "documents_only",
|
|
63
|
+
note: "no machine-readable financial API; filings are PDFs. Use search plus WebFetch on hkexnews.hk.",
|
|
64
|
+
},
|
|
65
|
+
CN: {
|
|
66
|
+
id: "CN",
|
|
67
|
+
suffixes: [".SS", ".SZ"],
|
|
68
|
+
regulator: "cninfo / 巨潮资讯",
|
|
69
|
+
tier: "none",
|
|
70
|
+
capability: "documents_only",
|
|
71
|
+
note: "no stable public API; filings are PDFs. Use search plus WebFetch on cninfo.com.cn.",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function marketFor(symbol) {
|
|
76
|
+
const text = String(symbol || "").trim().toUpperCase();
|
|
77
|
+
if (!text) return null;
|
|
78
|
+
for (const market of Object.values(MARKETS)) {
|
|
79
|
+
if (market.id === "US") continue;
|
|
80
|
+
if (market.suffixes.some((s) => s && text.endsWith(s))) return market;
|
|
81
|
+
}
|
|
82
|
+
// No recognised suffix means a plain US ticker.
|
|
83
|
+
return /^[A-Z0-9.\-]{1,10}$/.test(text) ? MARKETS.US : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Is a market's primary feed actually usable right now? */
|
|
87
|
+
export function feedStatus(market) {
|
|
88
|
+
if (!market) return { available: false, reason: "unrecognised market" };
|
|
89
|
+
if (market.tier === "keyless") return { available: true, tier: "keyless" };
|
|
90
|
+
if (market.tier === "free_key") {
|
|
91
|
+
const configured = Boolean(process.env[market.env]);
|
|
92
|
+
return {
|
|
93
|
+
available: configured,
|
|
94
|
+
tier: "free_key",
|
|
95
|
+
env: market.env,
|
|
96
|
+
reason: configured ? undefined : `${market.regulator} needs ${market.env}; ${market.note}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { available: false, tier: "none", reason: market.note };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Taiwan: the one non-US feed that needs no key -------------------------
|
|
103
|
+
|
|
104
|
+
let twseCache = null;
|
|
105
|
+
let twseCachedAt = 0;
|
|
106
|
+
const TWSE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
107
|
+
|
|
108
|
+
async function twseIncomeStatements() {
|
|
109
|
+
if (twseCache && Date.now() - twseCachedAt < TWSE_TTL_MS) return twseCache;
|
|
110
|
+
const ctrl = new AbortController();
|
|
111
|
+
const timer = setTimeout(() => ctrl.abort(), LIMITS.QUOTE_FETCH_MS * 2);
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch("https://openapi.twse.com.tw/v1/opendata/t187ap06_L_ci", {
|
|
114
|
+
signal: ctrl.signal,
|
|
115
|
+
headers: { Accept: "application/json" },
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
118
|
+
twseCache = await res.json();
|
|
119
|
+
twseCachedAt = Date.now();
|
|
120
|
+
return twseCache;
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read a TWSE field by name, returning null when the field is absent.
|
|
128
|
+
*
|
|
129
|
+
* TWSE uses full-width parentheses -- 營業毛利(毛損), not 營業毛利(毛損). Getting that
|
|
130
|
+
* wrong made gross profit and operating income read as 0 for every Taiwanese company:
|
|
131
|
+
* a wrong number that looks like a real one, which is worse than a gap.
|
|
132
|
+
*/
|
|
133
|
+
const twField = (row, key) => {
|
|
134
|
+
if (!(key in row)) throw new Error(`TWSE field "${key}" is not in the dataset; the schema changed`);
|
|
135
|
+
const raw = String(row[key] ?? "").trim();
|
|
136
|
+
if (!raw) return null;
|
|
137
|
+
const n = Number(raw.replace(/,/g, ""));
|
|
138
|
+
return Number.isFinite(n) ? n : null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Latest reported quarter for a Taiwan listed company, in TWD thousands as filed. */
|
|
142
|
+
export async function fetchTaiwanFinancials(symbol) {
|
|
143
|
+
const code = String(symbol).toUpperCase().replace(/\.(TW|TWO)$/, "");
|
|
144
|
+
const rows = await twseIncomeStatements();
|
|
145
|
+
const match = rows.filter((r) => String(r["公司代號"]) === code);
|
|
146
|
+
if (!match.length) return null;
|
|
147
|
+
const row = match[match.length - 1];
|
|
148
|
+
return {
|
|
149
|
+
market: "TW",
|
|
150
|
+
source: "TWSE OpenAPI",
|
|
151
|
+
company_code: code,
|
|
152
|
+
company_name: row["公司名稱"],
|
|
153
|
+
period: { year: row["年度"], quarter: row["季別"] },
|
|
154
|
+
reported_at: row["出表日期"],
|
|
155
|
+
currency: "TWD",
|
|
156
|
+
unit: "thousands as filed",
|
|
157
|
+
// TWSE reports the ROC calendar; 115 is 2026.
|
|
158
|
+
calendar: "ROC",
|
|
159
|
+
gregorian_year: Number(row["年度"]) + 1911,
|
|
160
|
+
revenue: twField(row, "營業收入"),
|
|
161
|
+
cost_of_revenue: twField(row, "營業成本"),
|
|
162
|
+
gross_profit: twField(row, "營業毛利(毛損)"),
|
|
163
|
+
operating_expenses: twField(row, "營業費用"),
|
|
164
|
+
operating_income: twField(row, "營業利益(損失)"),
|
|
165
|
+
pretax_income: twField(row, "稅前淨利(淨損)"),
|
|
166
|
+
net_income: twField(row, "本期淨利(淨損)"),
|
|
167
|
+
eps: twField(row, "基本每股盈餘(元)"),
|
|
168
|
+
note: "Quarterly summary only. TWSE publishes no full XBRL history, so multi-year rules cannot be computed from this source.",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Structured financials for any market, degrading in a stated order.
|
|
174
|
+
*
|
|
175
|
+
* Never returns an empty result silently: when the primary feed is unavailable it says
|
|
176
|
+
* which feed, why, and what the caller should do instead.
|
|
177
|
+
*/
|
|
178
|
+
export async function fetchMarketFinancials(symbol, extra = {}) {
|
|
179
|
+
const market = marketFor(symbol);
|
|
180
|
+
if (!market) throw invalidParams(`cannot identify a market for symbol "${symbol}"`);
|
|
181
|
+
const status = feedStatus(market);
|
|
182
|
+
|
|
183
|
+
const base = {
|
|
184
|
+
symbol,
|
|
185
|
+
market: market.id,
|
|
186
|
+
regulator: market.regulator,
|
|
187
|
+
feed: status,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (market.id === "US") {
|
|
191
|
+
return {
|
|
192
|
+
...base,
|
|
193
|
+
financials: null,
|
|
194
|
+
guidance: "US filer: use list_us_universe to resolve the CIK, then screen_ticker or compose_research_brief for full XBRL history.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (market.id === "TW" && status.available) {
|
|
199
|
+
const financials = await fetchTaiwanFinancials(symbol).catch(() => null);
|
|
200
|
+
if (financials) return { ...base, financials, guidance: financials.note };
|
|
201
|
+
return { ...base, financials: null, guidance: `Not found in the TWSE dataset for ${symbol}. Fall back to the quote and to search.` };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (market.id === "KR" && status.available) {
|
|
205
|
+
const kr = await fetchDartFinancials({ corpCode: extra.corp_code, year: extra.year || new Date().getFullYear() - 1 })
|
|
206
|
+
.catch((e) => ({ available: false, reason: String(e?.message || e) }));
|
|
207
|
+
if (kr.available) return { ...base, financials: kr, guidance: kr.note };
|
|
208
|
+
return { ...base, financials: null, guidance: kr.reason };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (market.id === "JP" && status.available) {
|
|
212
|
+
const jp = await fetchEdinetFilings({ secCode: symbol }).catch((e) => ({ available: false, reason: String(e?.message || e) }));
|
|
213
|
+
if (jp.available) return { ...base, financials: null, filings: jp.filings, guidance: jp.note };
|
|
214
|
+
return { ...base, financials: null, guidance: jp.reason };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Everything else: say what is missing and fall back to what always works.
|
|
218
|
+
const quote = await fetchQuote(symbol).catch((e) => ({ error: String(e?.message || e) }));
|
|
219
|
+
return {
|
|
220
|
+
...base,
|
|
221
|
+
financials: null,
|
|
222
|
+
fallback: {
|
|
223
|
+
quote: quote && !quote.error ? quote : null,
|
|
224
|
+
quote_error: quote?.error,
|
|
225
|
+
// A price is not a financial statement and the report must not treat it as one.
|
|
226
|
+
caveat: "A quote is market data, not a financial statement. Any statement about margins, "
|
|
227
|
+
+ "cash flow or leverage for this name has to come from search over primary documents, "
|
|
228
|
+
+ "and must be cited as such.",
|
|
229
|
+
},
|
|
230
|
+
guidance: status.available
|
|
231
|
+
? `No structured adapter for ${market.regulator} yet. Use search plus WebFetch on the regulator's site.`
|
|
232
|
+
: `${status.reason} Until then, use search plus WebFetch on the regulator's site and label the figures as sourced from documents rather than a structured feed.`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What the pipeline can and cannot do for a set of symbols. Answers it up front. */
|
|
237
|
+
export function coverageFor(symbols = []) {
|
|
238
|
+
const rows = symbols.map((symbol) => {
|
|
239
|
+
const market = marketFor(symbol);
|
|
240
|
+
const status = market ? feedStatus(market) : { available: false, reason: "unrecognised" };
|
|
241
|
+
return {
|
|
242
|
+
symbol,
|
|
243
|
+
market: market?.id ?? "unknown",
|
|
244
|
+
regulator: market?.regulator ?? "-",
|
|
245
|
+
structured_financials: status.available ? (market.capability === "summary" ? "summary only" : "yes") : "no",
|
|
246
|
+
reason: status.reason,
|
|
247
|
+
needs_env: market?.env && !status.available ? market.env : undefined,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
rows,
|
|
252
|
+
summary: {
|
|
253
|
+
full: rows.filter((r) => r.structured_financials === "yes").length,
|
|
254
|
+
summary_only: rows.filter((r) => r.structured_financials === "summary only").length,
|
|
255
|
+
none: rows.filter((r) => r.structured_financials === "no").length,
|
|
256
|
+
},
|
|
257
|
+
note: "Names without structured financials are not excluded from research -- they are researched from "
|
|
258
|
+
+ "documents and search, and every figure taken that way must be labelled as such so a reader can tell "
|
|
259
|
+
+ "which numbers came from a filing feed and which came from a document someone read.",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { fetchFeeds, MARKET_FEEDS, queryNewsFeed } from "./feeds.mjs";
|
|
2
|
+
import { getMacroSnapshot } from "./macro.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What story is the market currently telling itself, and does the tape agree?
|
|
6
|
+
*
|
|
7
|
+
* The pipeline could already read a company's filings and the macro tape, but not the
|
|
8
|
+
* thing that actually moves crowded positioning: the prevailing narrative. That gap
|
|
9
|
+
* matters because a name is repriced by the story it gets attached to long before its
|
|
10
|
+
* fundamentals change.
|
|
11
|
+
*
|
|
12
|
+
* The design rule is the separation: **headlines are what people are saying, the macro
|
|
13
|
+
* series is what happened.** Both are reported, never merged. When a theme dominates the
|
|
14
|
+
* press and the corresponding series has not moved, that divergence is the finding -- it
|
|
15
|
+
* is either a story running ahead of the data or a market that has stopped listening.
|
|
16
|
+
*
|
|
17
|
+
* Honest limitation, stated in the output: this can only find themes in its lexicon. It
|
|
18
|
+
* will not discover an unnamed theme, and it says so rather than implying full coverage.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Each theme carries the terms that identify it and the series that would corroborate it.
|
|
23
|
+
* `check` refers to a macro block member label or derived-measure id from macro.mjs, so a
|
|
24
|
+
* claim about rates is answered by the actual 10-year rather than by more headlines.
|
|
25
|
+
*/
|
|
26
|
+
export const THEMES = [
|
|
27
|
+
{
|
|
28
|
+
id: "rates_policy",
|
|
29
|
+
label: { zh: "利率与货币政策", en: "Rates and monetary policy" },
|
|
30
|
+
terms: ["rate cut", "rate hike", "interest rate", "federal reserve", "fed ", "fomc", "powell",
|
|
31
|
+
"monetary policy", "basis point", "tightening", "easing", "quantitative"],
|
|
32
|
+
check: { series: ["US 10Y", "US 3M"], derived: ["spread_10y_3m"] },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "treasury_yields",
|
|
36
|
+
label: { zh: "美债收益率与财政", en: "Treasury yields and fiscal" },
|
|
37
|
+
terms: ["treasury yield", "bond market", "10-year", "30-year", "debt ceiling", "deficit",
|
|
38
|
+
"bond selloff", "auction", "fiscal", "sovereign debt"],
|
|
39
|
+
check: { series: ["US 10Y", "US 30Y"], derived: ["spread_10y_3m"] },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "inflation",
|
|
43
|
+
label: { zh: "通胀", en: "Inflation" },
|
|
44
|
+
terms: ["inflation", "cpi", "ppi", "price pressure", "disinflation", "core pce", "cost of living"],
|
|
45
|
+
check: { series: ["Gold", "Crude"], derived: [] },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "ai_capex",
|
|
49
|
+
label: { zh: "AI 与资本开支周期", en: "AI and the capex cycle" },
|
|
50
|
+
terms: ["artificial intelligence", " ai ", "data center", "datacenter", "capex", "capital expenditure",
|
|
51
|
+
"hyperscaler", "gpu", "accelerator", "compute", "model training", "inference"],
|
|
52
|
+
check: { series: ["Nasdaq 100", "Semiconductors"], derived: [] },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "geopolitics",
|
|
56
|
+
label: { zh: "地缘政治与能源", en: "Geopolitics and energy" },
|
|
57
|
+
terms: ["iran", "israel", "ukraine", "russia", "strait", "middle east", "military strike",
|
|
58
|
+
"sanction", "war", "conflict", "opec", "oil supply"],
|
|
59
|
+
check: { series: ["Crude", "Gold"], derived: [] },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "trade_tariffs",
|
|
63
|
+
label: { zh: "关税与贸易", en: "Tariffs and trade" },
|
|
64
|
+
terms: ["tariff", "trade war", "export control", "import duty", "trade deal", "supply chain",
|
|
65
|
+
"decoupling", "entity list", "chip export"],
|
|
66
|
+
check: { series: ["Dollar index"], derived: [] },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "credit_stress",
|
|
70
|
+
label: { zh: "信用与流动性压力", en: "Credit and liquidity stress" },
|
|
71
|
+
terms: ["credit spread", "default", "bankruptcy", "refinancing risk", "refinancing wall",
|
|
72
|
+
"high yield", "distressed", "bank failure", "liquidity crunch", "credit downgrade",
|
|
73
|
+
"debt maturity", "covenant"],
|
|
74
|
+
check: { series: ["High yield", "Investment grade", "VIX"], derived: ["hyg_lqd"] },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "growth_recession",
|
|
78
|
+
label: { zh: "增长与衰退", en: "Growth and recession" },
|
|
79
|
+
terms: ["recession", "slowdown", "soft landing", "hard landing", "gdp", "unemployment",
|
|
80
|
+
"jobless", "payroll", "layoff", "consumer spending"],
|
|
81
|
+
check: { series: ["Copper", "Gold"], derived: ["copper_gold"] },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "dollar_fx",
|
|
85
|
+
label: { zh: "美元与汇率", en: "Dollar and currencies" },
|
|
86
|
+
terms: ["dollar index", "currency", "yen", "euro", "devalu", "forex", "exchange rate", "peg"],
|
|
87
|
+
check: { series: ["Dollar index"], derived: [] },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "china",
|
|
91
|
+
label: { zh: "中国", en: "China" },
|
|
92
|
+
terms: ["china", "beijing", "yuan", "renminbi", "property developer", "stimulus", "pboc"],
|
|
93
|
+
check: { series: ["China A", "Hong Kong"], derived: [] },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "earnings_season",
|
|
97
|
+
label: { zh: "财报季", en: "Earnings season" },
|
|
98
|
+
terms: ["earnings", "guidance", "beat estimates", "missed estimates", "quarterly results",
|
|
99
|
+
"profit margin", "revenue growth", "outlook cut", "outlook raise"],
|
|
100
|
+
check: { series: ["S&P 500"], derived: [] },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "politics_policy",
|
|
104
|
+
label: { zh: "政治与监管", en: "Politics and regulation" },
|
|
105
|
+
terms: ["election", "white house", "congress", "regulation", "antitrust", "executive order",
|
|
106
|
+
"shutdown", "legislation", "president"],
|
|
107
|
+
check: { series: [], derived: [] },
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const normalize = (s) => ` ${String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()} `;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Match a term at a word boundary.
|
|
115
|
+
*
|
|
116
|
+
* Long terms match as a prefix so "tariff" catches "tariffs" and "refinanc" catches
|
|
117
|
+
* "refinancing". Short ones must be whole words, because a prefix rule on "ai" would fire
|
|
118
|
+
* on "aid" and "aircraft" and quietly inflate the AI theme -- the kind of error that makes
|
|
119
|
+
* a coverage share look authoritative while being wrong.
|
|
120
|
+
*/
|
|
121
|
+
const hasTerm = (text, term) => {
|
|
122
|
+
const t = normalize(term).trim();
|
|
123
|
+
if (!t) return false;
|
|
124
|
+
return t.length <= 3 ? text.includes(` ${t} `) : text.includes(` ${t}`);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Which themes does one headline touch? A headline may carry more than one. */
|
|
128
|
+
export function classify(title) {
|
|
129
|
+
const text = normalize(title);
|
|
130
|
+
const hits = [];
|
|
131
|
+
for (const theme of THEMES) {
|
|
132
|
+
const matched = theme.terms.filter((term) => hasTerm(text, term));
|
|
133
|
+
if (matched.length) hits.push({ id: theme.id, matched });
|
|
134
|
+
}
|
|
135
|
+
return hits;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Pull the corroborating numbers for a theme out of an already-fetched macro snapshot. */
|
|
139
|
+
function marketCheck(theme, macro) {
|
|
140
|
+
if (!macro) return { available: false, reason: "macro snapshot unavailable" };
|
|
141
|
+
const series = [];
|
|
142
|
+
for (const block of macro.blocks || []) {
|
|
143
|
+
for (const member of block.members || []) {
|
|
144
|
+
if (theme.check.series.includes(member.label)) {
|
|
145
|
+
const q = member.quote || {};
|
|
146
|
+
series.push({
|
|
147
|
+
label: member.label,
|
|
148
|
+
symbol: member.symbol,
|
|
149
|
+
value: q.price ?? null,
|
|
150
|
+
change_pct: q.change_pct ?? null,
|
|
151
|
+
quote_time: q.quote_time ?? null,
|
|
152
|
+
available: q.price != null,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const derived = (macro.derived || [])
|
|
158
|
+
.filter((d) => theme.check.derived.includes(d.id))
|
|
159
|
+
.map((d) => ({ id: d.id, label: d.label, value: d.value ?? null, available: Boolean(d.available) }));
|
|
160
|
+
if (!series.length && !derived.length) {
|
|
161
|
+
return { available: false, reason: "no series in this snapshot corroborates this theme" };
|
|
162
|
+
}
|
|
163
|
+
return { available: true, series, derived };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function getMarketNarrative({ days = 7, asOf = null, extra_queries = [], top = 6 } = {}) {
|
|
167
|
+
const specs = [
|
|
168
|
+
...MARKET_FEEDS,
|
|
169
|
+
...(Array.isArray(extra_queries) ? extra_queries.slice(0, 4).map((q) => queryNewsFeed(q)) : []),
|
|
170
|
+
];
|
|
171
|
+
// The narrative and the tape are fetched independently on purpose; neither informs the other.
|
|
172
|
+
const [news, macro] = await Promise.all([
|
|
173
|
+
fetchFeeds(specs, { days, asOf }),
|
|
174
|
+
getMacroSnapshot({}).catch(() => null),
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const counts = new Map(THEMES.map((t) => [t.id, []]));
|
|
178
|
+
let unclassified = 0;
|
|
179
|
+
for (const item of news.items) {
|
|
180
|
+
const hits = classify(item.title);
|
|
181
|
+
if (!hits.length) { unclassified += 1; continue; }
|
|
182
|
+
for (const hit of hits) counts.get(hit.id).push({ ...item, matched: hit.matched });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const total = news.items.length || 1;
|
|
186
|
+
const themes = THEMES
|
|
187
|
+
.map((theme) => {
|
|
188
|
+
const items = counts.get(theme.id);
|
|
189
|
+
const check = marketCheck(theme, macro);
|
|
190
|
+
return {
|
|
191
|
+
id: theme.id,
|
|
192
|
+
label: theme.label,
|
|
193
|
+
headline_count: items.length,
|
|
194
|
+
share_of_coverage_pct: Number(((items.length / total) * 100).toFixed(1)),
|
|
195
|
+
market_check: check,
|
|
196
|
+
// Sample headlines carry their timestamp and link so any claim can be reopened.
|
|
197
|
+
sample: items.slice(0, 4).map((i) => ({
|
|
198
|
+
title: i.title, source: i.source, published_at: i.published_at, link: i.link, matched: i.matched,
|
|
199
|
+
})),
|
|
200
|
+
};
|
|
201
|
+
})
|
|
202
|
+
.filter((t) => t.headline_count > 0)
|
|
203
|
+
.sort((a, b) => b.headline_count - a.headline_count)
|
|
204
|
+
.slice(0, top);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
as_of: asOf || new Date().toISOString().slice(0, 10),
|
|
208
|
+
window_days: days,
|
|
209
|
+
headlines_in_window: news.items.length,
|
|
210
|
+
excluded_outside_window: news.excluded_outside_window,
|
|
211
|
+
excluded_sample: news.excluded_sample,
|
|
212
|
+
feeds: news.feeds,
|
|
213
|
+
unreachable: news.unreachable,
|
|
214
|
+
themes,
|
|
215
|
+
unclassified_headlines: unclassified,
|
|
216
|
+
interpretation_rules: [
|
|
217
|
+
"Headline counts measure what is being said, not what is true. A theme leading coverage "
|
|
218
|
+
+ "is evidence about attention and positioning, never about the underlying fact.",
|
|
219
|
+
"Where a theme dominates coverage and its market_check series has not moved, that gap is "
|
|
220
|
+
+ "the finding: either the story is ahead of the data, or the market has stopped listening. "
|
|
221
|
+
+ "Say which you think it is and why.",
|
|
222
|
+
"Never let a theme's prominence change a conclusion on a single name by itself. It sets "
|
|
223
|
+
+ "the environment the name is being priced in; it is not evidence about the business.",
|
|
224
|
+
],
|
|
225
|
+
coverage_limits: [
|
|
226
|
+
`Themes are matched against a fixed lexicon of ${THEMES.length} entries, so a genuinely new `
|
|
227
|
+
+ "narrative will land in unclassified_headlines rather than being discovered. "
|
|
228
|
+
+ `${unclassified} headlines in this window matched nothing.`,
|
|
229
|
+
"English-language feeds only. A theme leading the Asian or European press and absent here "
|
|
230
|
+
+ "is a blind spot of this tool, not an absence of the theme.",
|
|
231
|
+
"No social media. Positioning talk that lives on X or in group chats is not visible to "
|
|
232
|
+
+ "this layer, and a crowded trade can be crowded without appearing in any headline.",
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
}
|