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,140 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DEBATE_ROLES, RUNS_DIR } from "./constants.mjs";
5
+ import { invalidParams } from "./errors.mjs";
6
+ import { readJson, writeJson } from "./fsutil.mjs";
7
+ import { agentState, completenessStatus, sourceManifest, taskState, verificationStatus } from "./gates.mjs";
8
+
9
+ export { agentState, taskState };
10
+
11
+ export function today() {
12
+ return new Date().toISOString().slice(0, 10);
13
+ }
14
+
15
+ export function runId(symbol) {
16
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
17
+ return `${symbol.toUpperCase()}-${stamp}`;
18
+ }
19
+
20
+ export function safeSymbol(symbol) {
21
+ if (typeof symbol !== "string" || !/^[A-Za-z0-9.^=+\-]{1,32}$/.test(symbol)) {
22
+ throw invalidParams("symbol must be 1-32 chars and contain only ticker-safe characters.");
23
+ }
24
+ if (/^\.+$/.test(symbol)) throw invalidParams("symbol cannot be only dots.");
25
+ return symbol.toUpperCase();
26
+ }
27
+
28
+ export function runPath(id) {
29
+ if (typeof id !== "string" || !/^[A-Z0-9.^=+\-_]{1,80}$/.test(id)) {
30
+ throw invalidParams("run_id is invalid.");
31
+ }
32
+ return join(RUNS_DIR, id);
33
+ }
34
+
35
+ /**
36
+ * How much material the evidence agents actually found, counted by grade.
37
+ * A run that is mostly C is not necessarily wrong, but it is a different kind of report
38
+ * and the reader should be told before the conclusion, not after.
39
+ */
40
+ export function richnessSummary(run) {
41
+ const counts = { A: 0, B: 0, C: 0, unrated: 0 };
42
+ for (const packet of run.packets || []) {
43
+ const grade = ["A", "B", "C"].includes(packet.information_richness) ? packet.information_richness : "unrated";
44
+ counts[grade] += 1;
45
+ }
46
+ return counts;
47
+ }
48
+
49
+ export function statusSnapshot(run) {
50
+ const gate = verificationStatus(run);
51
+ const completeness = completenessStatus(run);
52
+ return {
53
+ run_id: run.run_id,
54
+ symbol: run.symbol,
55
+ as_of: run.as_of,
56
+ language: run.language,
57
+ execution_mode: run.execution_mode,
58
+ visibility_required: run.visibility_required,
59
+ dry_run: run.dry_run,
60
+ status: run.status,
61
+ phase: run.phase,
62
+ verification: gate.verification,
63
+ missing_source_count: gate.missing_claim_source_ids.length,
64
+ completeness: completeness.completeness,
65
+ missing_evidence_count: completeness.missing_evidence_count,
66
+ missing_debate_count: completeness.missing_debate_count,
67
+ information_richness: richnessSummary(run),
68
+ report_quality: run.report_quality?.status || "not_checked",
69
+ missing_report_items_count: run.report_quality?.missing?.length || 0,
70
+ started_at: run.started_at,
71
+ updated_at: run.updated_at,
72
+ completed_at: run.completed_at,
73
+ tasks: run.tasks.map((task) => taskState(run, task)),
74
+ agents: DEBATE_ROLES.map((role) => agentState(run, role)),
75
+ };
76
+ }
77
+
78
+ export function writeStatus(run, patch = {}) {
79
+ Object.assign(run, patch, { updated_at: new Date().toISOString() });
80
+ writeJson(join(runPath(run.run_id), "status.json"), statusSnapshot(run));
81
+ }
82
+
83
+ export function appendEvent(run, type, data = {}) {
84
+ appendFileSync(join(runPath(run.run_id), "events.jsonl"), `${JSON.stringify({
85
+ at: new Date().toISOString(),
86
+ type,
87
+ ...data,
88
+ })}\n`);
89
+ }
90
+
91
+ export function writeSourceManifest(run) {
92
+ writeJson(join(runPath(run.run_id), "source_manifest.json"), sourceManifest(run));
93
+ }
94
+
95
+ export function updateTask(run, task, status, patch = {}) {
96
+ run.task_status[task] = { ...taskState(run, task), ...patch, task, status, updated_at: new Date().toISOString() };
97
+ writeStatus(run);
98
+ appendEvent(run, `task_${status}`, { task, ...patch });
99
+ }
100
+
101
+ export function updateAgent(run, role, status, patch = {}) {
102
+ run.agent_status[role] = { ...agentState(run, role), ...patch, role, status, updated_at: new Date().toISOString() };
103
+ writeStatus(run);
104
+ appendEvent(run, `agent_${status}`, { role, ...patch });
105
+ }
106
+
107
+ export function artifactPaths(run) {
108
+ const dir = runPath(run.run_id);
109
+ const analyst_markdown = Object.fromEntries(
110
+ [...(run.tasks || []), ...DEBATE_ROLES].map((role) => [role, join(dir, `${role}.md`)])
111
+ );
112
+ return {
113
+ run_dir: dir,
114
+ final_report_md: join(dir, "final_report.md"),
115
+ user_response_md: join(dir, "user_response.md"),
116
+ artifact_index_md: join(dir, "artifact_index.md"),
117
+ all_agents_md: join(dir, "all_agents.md"),
118
+ evidence_json: join(dir, "evidence.json"),
119
+ source_manifest_json: join(dir, "source_manifest.json"),
120
+ status_json: join(dir, "status.json"),
121
+ events_jsonl: join(dir, "events.jsonl"),
122
+ report_quality_json: join(dir, "report_quality.json"),
123
+ decision_json: join(dir, "decision.json"),
124
+ analyst_markdown,
125
+ };
126
+ }
127
+
128
+ export function existingDebate(dir) {
129
+ return {
130
+ bull: existsSync(join(dir, "bull_researcher.json")) ? readJson(join(dir, "bull_researcher.json")) : null,
131
+ bear: existsSync(join(dir, "bear_researcher.json")) ? readJson(join(dir, "bear_researcher.json")) : null,
132
+ manager: existsSync(join(dir, "manager_synthesis.json")) ? readJson(join(dir, "manager_synthesis.json")) : null,
133
+ };
134
+ }
135
+
136
+ export function saveRun(run) {
137
+ writeJson(join(runPath(run.run_id), "evidence.json"), run);
138
+ writeSourceManifest(run);
139
+ writeStatus(run);
140
+ }
@@ -0,0 +1,283 @@
1
+ import { fetchUniverse, fetchCompanyFacts, annualSeries, CONCEPTS } from "./sec.mjs";
2
+ import { invalidParams } from "./errors.mjs";
3
+
4
+ /**
5
+ * Mechanical elimination screen. No language model is involved.
6
+ *
7
+ * This is the layer that makes stock-finding honest: an LLM asked to "recommend some
8
+ * stocks" returns the names most frequent in its training data attached to hallucinated
9
+ * figures. Here every rejection names the metric, the computed value and the threshold,
10
+ * so any verdict can be checked or argued with.
11
+ *
12
+ * The rules eliminate; they never select. Surviving is not a recommendation, it means
13
+ * "worth spending research time on".
14
+ */
15
+
16
+ const pct = (x) => (x === null ? null : Number((x * 100).toFixed(2)));
17
+ const last = (series, n) => series.slice(-n);
18
+ const sum = (xs) => xs.reduce((a, b) => a + b, 0);
19
+
20
+ function values(facts, key, asOf) {
21
+ const found = annualSeries(facts, CONCEPTS[key], { asOf });
22
+ return found ? found.series.map((e) => ({ end: e.end, filed: e.filed, val: e.val })) : [];
23
+ }
24
+
25
+ /**
26
+ * Seven elimination rules with three exemptions.
27
+ * Each returns { id, passed, value, threshold, reason } or { skipped } when the inputs
28
+ * are not available -- a missing input is never silently treated as a pass.
29
+ */
30
+ export function evaluateRules(facts, { asOf = null } = {}) {
31
+ const revenue = values(facts, "revenue", asOf);
32
+ const netIncome = values(facts, "netIncome", asOf);
33
+ const grossProfit = values(facts, "grossProfit", asOf);
34
+ const operatingIncome = values(facts, "operatingIncome", asOf);
35
+ const ocf = values(facts, "operatingCashFlow", asOf);
36
+ const capex = values(facts, "capex", asOf);
37
+ const equity = values(facts, "equity", asOf);
38
+ const interest = values(facts, "interestExpense", asOf);
39
+ const shares = values(facts, "sharesOutstanding", asOf);
40
+
41
+ const rules = [];
42
+ const add = (id, label, compute) => {
43
+ try {
44
+ const result = compute();
45
+ rules.push(result === null ? { id, label, skipped: true, reason: "inputs unavailable in filings" } : { id, label, ...result });
46
+ } catch (error) {
47
+ rules.push({ id, label, skipped: true, reason: `could not compute: ${error.message}` });
48
+ }
49
+ };
50
+
51
+ add("roe_10y", { en: "10-year average ROE", zh: "10年平均ROE" }, () => {
52
+ const ni = last(netIncome, 10);
53
+ const eq = last(equity, 10);
54
+ if (ni.length < 5 || eq.length < 5) return null;
55
+ const n = Math.min(ni.length, eq.length);
56
+ const roes = [];
57
+ for (let i = 0; i < n; i += 1) {
58
+ const e = eq[eq.length - n + i].val;
59
+ if (e > 0) roes.push(ni[ni.length - n + i].val / e);
60
+ }
61
+ if (roes.length < 5) return null;
62
+ const avg = sum(roes) / roes.length;
63
+ return { passed: avg >= 0.08, value: pct(avg), unit: "%", threshold: 8, direction: "min", years: roes.length };
64
+ });
65
+
66
+ add("fcf_5y", { en: "5-year cumulative free cash flow", zh: "5年累计自由现金流" }, () => {
67
+ const o = last(ocf, 5);
68
+ const c = last(capex, 5);
69
+ if (o.length < 3 || c.length < 3) return null;
70
+ const n = Math.min(o.length, c.length);
71
+ const total = sum(o.slice(-n).map((x) => x.val)) - sum(c.slice(-n).map((x) => x.val));
72
+ // Raw dollars, not billions rounded to two places: rounding erased the entire figure
73
+ // for anything below ~$5m, which is most of the small-cap universe.
74
+ return { passed: total >= 0, value: Math.round(total), unit: "USD", threshold: 0, direction: "min", years: n };
75
+ });
76
+
77
+ add("interest_cover", { en: "EBIT / interest cover", zh: "利息保障倍数" }, () => {
78
+ const ebit = last(operatingIncome, 1)[0];
79
+ const int = last(interest, 1)[0];
80
+ if (!ebit || !int || int.val === 0) return null;
81
+ const cover = ebit.val / Math.abs(int.val);
82
+ return { passed: cover >= 2, value: Number(cover.toFixed(2)), unit: "x", threshold: 2, direction: "min" };
83
+ });
84
+
85
+ add("gross_margin", { en: "long-run gross margin", zh: "长期毛利率" }, () => {
86
+ const gp = last(grossProfit, 5);
87
+ const rev = last(revenue, 5);
88
+ if (gp.length < 3 || rev.length < 3) return null;
89
+ const n = Math.min(gp.length, rev.length);
90
+ const margins = [];
91
+ for (let i = 0; i < n; i += 1) {
92
+ const r = rev[rev.length - n + i].val;
93
+ if (r > 0) margins.push(gp[gp.length - n + i].val / r);
94
+ }
95
+ if (!margins.length) return null;
96
+ const avg = sum(margins) / margins.length;
97
+ return { passed: avg >= 0.15, value: pct(avg), unit: "%", threshold: 15, direction: "min", years: margins.length };
98
+ });
99
+
100
+ add("ocf_over_ni", { en: "5-year OCF / net income", zh: "5年经营现金流/净利" }, () => {
101
+ const o = last(ocf, 5);
102
+ const ni = last(netIncome, 5);
103
+ if (o.length < 3 || ni.length < 3) return null;
104
+ const n = Math.min(o.length, ni.length);
105
+ const totalNi = sum(ni.slice(-n).map((x) => x.val));
106
+ if (totalNi <= 0) return null;
107
+ const ratio = sum(o.slice(-n).map((x) => x.val)) / totalNi;
108
+ return { passed: ratio >= 0.7, value: Number(ratio.toFixed(2)), unit: "x", threshold: 0.7, direction: "min", years: n };
109
+ });
110
+
111
+ add("net_margin", { en: "long-run net margin", zh: "长期净利率" }, () => {
112
+ const ni = last(netIncome, 5);
113
+ const rev = last(revenue, 5);
114
+ if (ni.length < 3 || rev.length < 3) return null;
115
+ const n = Math.min(ni.length, rev.length);
116
+ const totalRev = sum(rev.slice(-n).map((x) => x.val));
117
+ if (totalRev <= 0) return null;
118
+ const margin = sum(ni.slice(-n).map((x) => x.val)) / totalRev;
119
+ return { passed: margin >= 0.05, value: pct(margin), unit: "%", threshold: 5, direction: "min", years: n };
120
+ });
121
+
122
+ add("dilution", { en: "5-year share dilution", zh: "5年股本稀释" }, () => {
123
+ const s = last(shares, 5);
124
+ if (s.length < 3) return null;
125
+ const first = s[0].val;
126
+ const latest = s[s.length - 1].val;
127
+ if (!(first > 0)) return null;
128
+ const change = latest / first - 1;
129
+ return { passed: change <= 0.20, value: pct(change), unit: "%", threshold: 20, direction: "max", years: s.length };
130
+ });
131
+
132
+ // Exemptions. These are the only legitimate way past a failed rule -- a good story is
133
+ // not, and the screen deliberately gives no mechanism for one.
134
+ const exemptions = [];
135
+ const rule = (id) => rules.find((r) => r.id === id);
136
+ const failed = (id) => rule(id) && rule(id).passed === false;
137
+
138
+ const gm = rule("gross_margin");
139
+ const historyYears = revenue.length;
140
+ if (failed("roe_10y") && historyYears < 10 && gm?.value > 30 && last(ocf, 2).every((x) => x.val > 0)) {
141
+ exemptions.push({
142
+ rule: "roe_10y",
143
+ reason: `listed under 10 years (${historyYears}y of filings) with gross margin ${gm.value}% and positive recent operating cash flow`,
144
+ });
145
+ }
146
+ if (failed("fcf_5y") && gm?.value > 50) {
147
+ exemptions.push({ rule: "fcf_5y", reason: `gross margin ${gm.value}% suggests reinvestment rather than an unprofitable model -- verify manually` });
148
+ }
149
+ if (failed("net_margin") && rule("ocf_over_ni") && rule("ocf_over_ni").value > 1.5) {
150
+ exemptions.push({ rule: "net_margin", reason: `OCF/NI of ${rule("ocf_over_ni").value}x indicates heavy non-cash charges depressing reported margin` });
151
+ }
152
+
153
+ const exempted = new Set(exemptions.map((e) => e.rule));
154
+ const failures = rules.filter((r) => r.passed === false && !exempted.has(r.id));
155
+ const skipped = rules.filter((r) => r.skipped);
156
+
157
+ return {
158
+ rules,
159
+ exemptions,
160
+ failures,
161
+ skipped_count: skipped.length,
162
+ verdict: failures.length === 0 ? "survives" : "eliminated",
163
+ // Data coverage is reported, never assumed: a company that failed nothing because
164
+ // nothing could be computed is not the same as one that passed.
165
+ evaluated_count: rules.length - skipped.length,
166
+ };
167
+ }
168
+
169
+ export function explainResult(result, ticker) {
170
+ const lines = [`${ticker}: ${result.verdict}`];
171
+ if (result.failures.length) {
172
+ lines.push("eliminated by:");
173
+ for (const f of result.failures) {
174
+ lines.push(` - ${typeof f.label === "string" ? f.label : f.label.en}: measured ${f.value}${f.unit === "%" ? "%" : ` ${f.unit}`} against a threshold of ${f.threshold}${f.years ? ` over ${f.years}y` : ""}`);
175
+ }
176
+ }
177
+ if (result.exemptions.length) {
178
+ lines.push("exempted:");
179
+ for (const e of result.exemptions) lines.push(` - ${e.rule}: ${e.reason}`);
180
+ }
181
+ if (result.skipped_count) {
182
+ lines.push(`${result.skipped_count} of ${result.rules.length} rules could not be computed from filings and were NOT treated as passes.`);
183
+ }
184
+ return lines.join("\n");
185
+ }
186
+
187
+ export async function screenTicker({ cik, ticker, asOf = null }) {
188
+ // The tool schema offers `ticker`, so callers pass one. Demanding a CIK anyway turned a
189
+ // documented argument into an error and made the caller go look up an identifier the
190
+ // universe file already holds -- the opposite of working without configuration.
191
+ let resolved = cik;
192
+ if (!resolved && ticker) {
193
+ const wanted = String(ticker).trim().toUpperCase();
194
+ const universe = await fetchUniverse();
195
+ const hit = universe.find((row) => String(row.ticker).toUpperCase() === wanted);
196
+ if (!hit) {
197
+ throw invalidParams(
198
+ `no US filer with ticker "${ticker}" in the SEC universe. `
199
+ + "Non-US listings are absent from it; supply a cik, or use market_coverage to see what this market supports.",
200
+ );
201
+ }
202
+ resolved = hit.cik;
203
+ }
204
+ if (!resolved) throw invalidParams("screenTicker needs a cik or a ticker");
205
+ const cikUsed = resolved;
206
+ const facts = await fetchCompanyFacts(cikUsed);
207
+ const result = evaluateRules(facts, { asOf });
208
+ return { ticker: ticker || facts.entityName, cik: cikUsed, resolved_from_ticker: !cik && Boolean(ticker), entity: facts.entityName, as_of: asOf, ...result };
209
+ }
210
+
211
+
212
+ /**
213
+ * Screen a list of candidates and report every elimination with its reason.
214
+ *
215
+ * Deliberately bounded rather than "screen the whole market": each company is one SEC
216
+ * request at ~120ms, so 10,000 filers is twenty minutes of requests SEC would rightly
217
+ * throttle. A funnel narrows first -- by industry, index, or a name list -- and this
218
+ * layer eliminates mechanically from what is handed to it.
219
+ */
220
+ export async function screenBatch({ candidates = [], asOf = null, concurrency = 3 } = {}) {
221
+ if (!Array.isArray(candidates) || candidates.length === 0) {
222
+ throw invalidParams("screenBatch needs candidates: [{cik, ticker}]");
223
+ }
224
+ if (candidates.length > 40) {
225
+ throw invalidParams(`too many candidates (${candidates.length}). Narrow the funnel first -- SEC is one request per company and rate-limits. Cap is 40.`);
226
+ }
227
+
228
+ const results = [];
229
+ const queue = [...candidates];
230
+ const worker = async () => {
231
+ while (queue.length) {
232
+ const candidate = queue.shift();
233
+ try {
234
+ results.push(await screenTicker({ ...candidate, asOf }));
235
+ } catch (error) {
236
+ // A fetch failure is a data gap, never an elimination: silently dropping a name
237
+ // because SEC timed out would bias the survivors.
238
+ results.push({
239
+ ticker: candidate.ticker || candidate.cik,
240
+ cik: candidate.cik,
241
+ verdict: "unavailable",
242
+ error: String(error.message || error),
243
+ });
244
+ }
245
+ }
246
+ };
247
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(4, concurrency)) }, worker));
248
+
249
+ const survivors = results.filter((r) => r.verdict === "survives");
250
+ const eliminated = results.filter((r) => r.verdict === "eliminated");
251
+ const unavailable = results.filter((r) => r.verdict === "unavailable");
252
+
253
+ return {
254
+ as_of: asOf,
255
+ screened: results.length,
256
+ survivors: survivors.map((r) => ({
257
+ ticker: r.ticker,
258
+ cik: r.cik,
259
+ entity: r.entity,
260
+ rules_computed: r.evaluated_count,
261
+ rules_total: r.rules.length,
262
+ exemptions: r.exemptions,
263
+ })),
264
+ eliminated: eliminated.map((r) => ({
265
+ ticker: r.ticker,
266
+ cik: r.cik,
267
+ // The whole point: never "did not pass", always which metric at which value.
268
+ reasons: r.failures.map((f) => ({
269
+ rule: f.id,
270
+ label: f.label,
271
+ measured: f.value,
272
+ unit: f.unit,
273
+ threshold: f.threshold,
274
+ years: f.years,
275
+ })),
276
+ })),
277
+ unavailable: unavailable.map((r) => ({ ticker: r.ticker, cik: r.cik, error: r.error })),
278
+ disclaimer:
279
+ "Surviving is not a recommendation. These rules eliminate; they never select. A survivor is a name worth "
280
+ + "spending research time on, and the council still has to run. Rules whose inputs were missing from the "
281
+ + "filings were skipped rather than passed, so check rules_computed before treating a survivor as clean.",
282
+ };
283
+ }
@@ -0,0 +1,131 @@
1
+ import { LIMITS } from "./constants.mjs";
2
+ import { invalidParams } from "./errors.mjs";
3
+
4
+ /**
5
+ * Keyless SEC client.
6
+ *
7
+ * SEC requires a descriptive User-Agent with a contact and rate-limits to ~10 req/s.
8
+ * Everything here comes from filings, so a figure is what the company actually reported,
9
+ * not a vendor's adjusted version of it.
10
+ *
11
+ * The field that matters most is `filed`. A fiscal year ending 2024-09-28 was not public
12
+ * until 2024-11-01, so anything that reasons "as of" a date must filter on `filed`, not
13
+ * on the period end. Getting this wrong is the single easiest way to build a screen or a
14
+ * backtest that looks brilliant and is measuring the future.
15
+ */
16
+ // SEC's User-Agent rules are stricter than the published guidance and were established
17
+ // by testing, not by reading: it wants `Name/version (email)`, and it returns 403 with an
18
+ // HTML body when the contact contains a URL or a domain it associates with crawlers --
19
+ // including a github.com noreply address. Anyone running this at volume should set
20
+ // ALPHACOUNCIL_SEC_USER_AGENT to a real contact of their own.
21
+ const UA = process.env.ALPHACOUNCIL_SEC_USER_AGENT
22
+ || "AlphaCouncil-Agent/0.4 (alphacouncil@runbox.com)";
23
+
24
+ const MIN_INTERVAL_MS = 120; // stay under SEC's ~10 req/s guidance
25
+ let lastCall = 0;
26
+
27
+ async function throttle() {
28
+ const wait = lastCall + MIN_INTERVAL_MS - Date.now();
29
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
30
+ lastCall = Date.now();
31
+ }
32
+
33
+ async function secJson(url, timeoutMs = LIMITS.QUOTE_FETCH_MS * 2) {
34
+ await throttle();
35
+ const ctrl = new AbortController();
36
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
37
+ try {
38
+ const res = await fetch(url, {
39
+ signal: ctrl.signal,
40
+ headers: { "User-Agent": UA, Accept: "application/json" },
41
+ });
42
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
43
+ const text = await res.text();
44
+ if (text.trimStart().startsWith("<")) throw new Error(`SEC returned HTML rather than JSON for ${url} (rate limited or blocked)`);
45
+ return JSON.parse(text);
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+
51
+ /** The full US listed universe: ~10k entries of {cik, ticker, title}. */
52
+ export async function fetchUniverse() {
53
+ const raw = await secJson("https://www.sec.gov/files/company_tickers.json");
54
+ return Object.values(raw).map((row) => ({
55
+ cik: String(row.cik_str).padStart(10, "0"),
56
+ ticker: row.ticker,
57
+ title: row.title,
58
+ }));
59
+ }
60
+
61
+ export async function fetchCompanyFacts(cik) {
62
+ const padded = String(cik).replace(/\D/g, "").padStart(10, "0");
63
+ if (padded.length !== 10) throw invalidParams(`invalid CIK: ${cik}`);
64
+ return secJson(`https://data.sec.gov/api/xbrl/companyfacts/CIK${padded}.json`);
65
+ }
66
+
67
+ /**
68
+ * Annual series for a concept, newest last, filtered to what was public by `asOf`.
69
+ *
70
+ * Tries several tags because the same economic quantity has different names depending on
71
+ * when and under which taxonomy a company filed.
72
+ */
73
+ export function annualSeries(facts, tags, { asOf = null, unit = "USD" } = {}) {
74
+ const cutoff = asOf ? new Date(asOf).getTime() : null;
75
+ for (const tag of tags) {
76
+ const entries = facts?.facts?.["us-gaap"]?.[tag]?.units?.[unit];
77
+ if (!Array.isArray(entries) || entries.length === 0) continue;
78
+
79
+ const byPeriod = new Map();
80
+ for (const entry of entries) {
81
+ if (entry.form !== "10-K" || !entry.end || !Number.isFinite(entry.val)) continue;
82
+ // Look-ahead guard: a filing is only usable once it was actually filed.
83
+ if (cutoff && new Date(entry.filed).getTime() > cutoff) continue;
84
+ const prior = byPeriod.get(entry.end);
85
+ // Keep the most recently filed value for a period: restatements supersede.
86
+ if (!prior || new Date(entry.filed) > new Date(prior.filed)) byPeriod.set(entry.end, entry);
87
+ }
88
+ if (byPeriod.size === 0) continue;
89
+ const series = [...byPeriod.values()].sort((a, b) => new Date(a.end) - new Date(b.end));
90
+ return { tag, unit, series };
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** Concept aliases, ordered by preference. */
96
+ export const CONCEPTS = {
97
+ revenue: [
98
+ "RevenueFromContractWithCustomerExcludingAssessedTax",
99
+ "Revenues",
100
+ "SalesRevenueNet",
101
+ "RevenueFromContractWithCustomerIncludingAssessedTax",
102
+ ],
103
+ netIncome: ["NetIncomeLoss", "ProfitLoss"],
104
+ grossProfit: ["GrossProfit"],
105
+ operatingIncome: ["OperatingIncomeLoss"],
106
+ operatingCashFlow: ["NetCashProvidedByUsedInOperatingActivities", "NetCashProvidedByUsedInOperatingActivitiesContinuingOperations"],
107
+ capex: ["PaymentsToAcquirePropertyPlantAndEquipment", "PaymentsToAcquireProductiveAssets"],
108
+ equity: ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
109
+ assets: ["Assets"],
110
+ interestExpense: ["InterestExpense", "InterestIncomeExpenseNet", "InterestExpenseDebt"],
111
+ sharesOutstanding: ["CommonStockSharesOutstanding", "WeightedAverageNumberOfDilutedSharesOutstanding"],
112
+ };
113
+
114
+ export const secUserAgent = () => UA;
115
+
116
+ /** Company metadata including SIC industry classification. Keyless. */
117
+ export async function fetchSubmissions(cik) {
118
+ const padded = String(cik).replace(/\D/g, "").padStart(10, "0");
119
+ if (padded.length !== 10) throw invalidParams(`invalid CIK: ${cik}`);
120
+ const data = await secJson(`https://data.sec.gov/submissions/CIK${padded}.json`);
121
+ return {
122
+ cik: padded,
123
+ name: data.name,
124
+ tickers: data.tickers || [],
125
+ exchanges: data.exchanges || [],
126
+ sic: data.sic || null,
127
+ sic_description: data.sicDescription || null,
128
+ state_of_incorporation: data.stateOfIncorporation || null,
129
+ fiscal_year_end: data.fiscalYearEnd || null,
130
+ };
131
+ }