alphacouncil-agent 1.0.1 → 1.0.3

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 (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +82 -0
  5. package/README.md +24 -15
  6. package/README.zh-CN.md +18 -10
  7. package/data/authored/core-seats.mjs +11 -7
  8. package/data/authored/quant-seats.mjs +2 -2
  9. package/data/authored/value-seats.mjs +4 -4
  10. package/data/build-profile.v1.json +1 -1
  11. package/data/persona-v3-build-specs.v1.mjs +1 -1
  12. package/docs/plans/v1.1-say-something-worth-reading.md +116 -0
  13. package/knowledge/ai-assisted-solo/experiments/runs/a.json +2 -2
  14. package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
  15. package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
  16. package/knowledge/ai-assisted-solo/experiments/runs/d13.json +15 -15
  17. package/knowledge/ai-assisted-solo/experiments/runs/d26.json +27 -27
  18. package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +18 -18
  19. package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +30 -30
  20. package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +31 -31
  21. package/knowledge/ai-assisted-solo/experiments/simulation-input.json +9 -9
  22. package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
  23. package/knowledge/solo-test/masters/master_bogle/decision_policy.json +4 -3
  24. package/knowledge/solo-test/masters/master_bogle/provisional-index.json +3 -0
  25. package/knowledge/solo-test/masters/master_bogle/sources.jsonl +1 -1
  26. package/knowledge/solo-test/masters/master_graham/decision_policy.json +1 -1
  27. package/knowledge/solo-test/masters/master_klarman/decision_policy.json +1 -1
  28. package/knowledge/solo-test/masters/master_pabrai/decision_policy.json +1 -1
  29. package/mcp/lib/basket-news.mjs +181 -0
  30. package/mcp/lib/breadth.mjs +24 -0
  31. package/mcp/lib/constants.mjs +3 -0
  32. package/mcp/lib/cross-market.mjs +180 -0
  33. package/mcp/lib/fundamentals.mjs +11 -0
  34. package/mcp/lib/grounding.mjs +101 -3
  35. package/mcp/lib/industry.mjs +23 -2
  36. package/mcp/lib/insider-ownership.mjs +23 -12
  37. package/mcp/lib/instrument-facts.mjs +3 -0
  38. package/mcp/lib/markdown.mjs +41 -8
  39. package/mcp/lib/orchestrator.mjs +10 -2
  40. package/mcp/lib/personas/engine.mjs +66 -9
  41. package/mcp/lib/personas-v3/deterministic-executor.mjs +39 -2
  42. package/mcp/lib/personas-v3/grounding-adapter.mjs +143 -0
  43. package/mcp/lib/personas-v3/runtime.mjs +9 -2
  44. package/mcp/lib/screen.mjs +27 -1
  45. package/mcp/lib/sec.mjs +80 -17
  46. package/mcp/lib/voice-from-decision.mjs +211 -0
  47. package/package.json +1 -1
  48. package/scripts/lib/persona-v3-solo-formula-pipeline.mjs +11 -0
@@ -18,7 +18,7 @@
18
18
  "formal_ga_effect": "none",
19
19
  "formal_h_satisfied": false,
20
20
  "human_reference": false,
21
- "input_hash": "sha256:12ed14b1580ffc858af45f5dc4109d4fe3bcb7d89d20017f5103f3c770d9c213",
21
+ "input_hash": "sha256:cc4ffaa54c9477000383adc82ba43800c3725c331638f2f8f63cad15582a128f",
22
22
  "network_allowed": false,
23
23
  "production_effect": "none",
24
24
  "protocol_binding": {
@@ -31,14 +31,14 @@
31
31
  "reviewer_kind": "ai",
32
32
  "runtime_bindings": [
33
33
  {
34
- "byte_length": 27327,
34
+ "byte_length": 27650,
35
35
  "path": "mcp/lib/personas-v3/runtime.mjs",
36
- "physical_file_hash": "sha256:ad06e3d004cdb072909fc2fb9cf643dace28ff542a4c7092309fdd53f5623b63"
36
+ "physical_file_hash": "sha256:aa32cf59eaec9eeb9583e44b76b31fac8fb8661e49b0b19da6de0f9b4ca5f568"
37
37
  },
38
38
  {
39
- "byte_length": 49532,
39
+ "byte_length": 51789,
40
40
  "path": "mcp/lib/personas-v3/deterministic-executor.mjs",
41
- "physical_file_hash": "sha256:1222b8c56e5535ec1f446f55aee0a3c1b948f80403e53085de932398ae90f7ef"
41
+ "physical_file_hash": "sha256:ee45aed55448644c194e576b9f8611a9afd163e53b36f744ee687ae25ca99bdb"
42
42
  },
43
43
  {
44
44
  "byte_length": 8032,
@@ -1008,7 +1008,7 @@
1008
1008
  "schema_version": 1
1009
1009
  },
1010
1010
  "fact_pack_hash": "sha256:f69c424fb4ab9bb147ca933702857366ea492f8e988bfb2aeeb4e64df2c32706",
1011
- "pack_hash": "sha256:de88d2c0e811ac58876e7fd70c63d4dd75340e0f60164b960f21dad0db581aa7",
1011
+ "pack_hash": "sha256:f960a6c7061ae791935590e8d045478a65ea451335aceaed5c275894a9b8c9d7",
1012
1012
  "pack_manifest_binding": {
1013
1013
  "byte_length": 5436,
1014
1014
  "path": "knowledge/solo-test/masters/master_graham/manifest.json",
@@ -6322,7 +6322,7 @@
6322
6322
  "schema_version": 1
6323
6323
  },
6324
6324
  "fact_pack_hash": "sha256:0b481c9b13e34ec6465e5007d364473d71c462e3940726744fa6f91b9646f4d3",
6325
- "pack_hash": "sha256:c71dc3fb2042965075386177ee6dad16ff88abb056b8599146117437e0b19f15",
6325
+ "pack_hash": "sha256:a09fec122e5d4f43d8885a5b00e354d259768c7f4648c9f0eebb358cf0461134",
6326
6326
  "pack_manifest_binding": {
6327
6327
  "byte_length": 5399,
6328
6328
  "path": "knowledge/solo-test/masters/master_klarman/manifest.json",
@@ -6634,7 +6634,7 @@
6634
6634
  "schema_version": 1
6635
6635
  },
6636
6636
  "fact_pack_hash": "sha256:6735e9705be48f6b9c19db94360b13ad6727db15e288c4e1225664ddd1d1a5a1",
6637
- "pack_hash": "sha256:f48324d38fbe0f5b2f68a1c391298c618962f11bec362b83c46d462f5235953f",
6637
+ "pack_hash": "sha256:8ee9e13deaadaeef3065feb7bb5b39382181338cf1055b769b153cad4c308ce4",
6638
6638
  "pack_manifest_binding": {
6639
6639
  "byte_length": 5594,
6640
6640
  "path": "knowledge/solo-test/masters/master_pabrai/manifest.json",
@@ -8278,7 +8278,7 @@
8278
8278
  "schema_version": 1
8279
8279
  },
8280
8280
  "fact_pack_hash": "sha256:d035c7f7cbace9a80cce787b1ea3204f7b85437e4552dec34681765f0ab3af26",
8281
- "pack_hash": "sha256:73e829f5d2e3b1124689635cb70bbef8f764559603689a5eae9920716eaf5d1c",
8281
+ "pack_hash": "sha256:cbc29f36bcf214b24045d582bf954d06cda6cec3aab06c41f650f7410df510f0",
8282
8282
  "pack_manifest_binding": {
8283
8283
  "byte_length": 5640,
8284
8284
  "path": "knowledge/solo-test/masters/master_bogle/manifest.json",
@@ -6,11 +6,11 @@
6
6
  "formal_h_satisfied": false,
7
7
  "human_reference": false,
8
8
  "input_binding": {
9
- "content_hash": "sha256:c05cc5d09dbb4bac71c6f1cee891bc7d8ab6674a59ca8f828794f9a3a1d6eb5e",
10
- "input_hash": "sha256:12ed14b1580ffc858af45f5dc4109d4fe3bcb7d89d20017f5103f3c770d9c213",
9
+ "content_hash": "sha256:6340496a97245ce4bef30d91fdd7007e1128d7324ab28f55f8ff884a221a1363",
10
+ "input_hash": "sha256:cc4ffaa54c9477000383adc82ba43800c3725c331638f2f8f63cad15582a128f",
11
11
  "path": "simulation-input.json"
12
12
  },
13
- "manifest_hash": "sha256:ec018a1666edb9c7b69c5a6d21b5df60c592d99a35cfb6c50ad6fc3afff23cc2",
13
+ "manifest_hash": "sha256:bb7437c3661c054886c048ed258739064cd132b86fb286265e7f0b43e22cd2f3",
14
14
  "n_eff_disclosure_binding": {
15
15
  "artifact_hash": "sha256:42ddb52fb8c5cc6335a0c90bbb4989fcfcc0a626ea1a32c87697ba4335ec94ab",
16
16
  "content_hash": "sha256:18f4efc086ffa86322eeb9b442960b291f41811d92b7fc3f59db72dfb5cbc305",
@@ -32,66 +32,66 @@
32
32
  "run_bindings": [
33
33
  {
34
34
  "blocked_fail_closed_count": 0,
35
- "content_hash": "sha256:c1cbaee09b8df627681a3fcb4463708223490cd0a06815749a8c9ec8fe45ebb1",
35
+ "content_hash": "sha256:6370e7f572f4ebcdec74b2424cd4a8b7f2867bd788902d38319b09d7c966ce52",
36
36
  "executed_count": 1,
37
37
  "path": "runs/a.json",
38
- "run_artifact_hash": "sha256:e6667b6317419d31e8bcc403132977837c7e6403328aad9358748372bf87103a",
38
+ "run_artifact_hash": "sha256:699e79ff212ba32d287d8847828b46e6bd46076519c6dc8dca5e9ac09f6d769f",
39
39
  "run_id": "A"
40
40
  },
41
41
  {
42
42
  "blocked_fail_closed_count": 0,
43
- "content_hash": "sha256:1f735b8038f2e16f8acc7eabd9950eadffe43d75b6a3a012b12197582ab8769f",
43
+ "content_hash": "sha256:82ad061ab98755a8f62ac2007ef245f28c711f71bbb9e99d27d02c8a61a52451",
44
44
  "executed_count": 0,
45
45
  "path": "runs/b.json",
46
- "run_artifact_hash": "sha256:6c983e5bae0666b24981f9325b7381dd687402a7a229ffa57e2f8b775b28c49d",
46
+ "run_artifact_hash": "sha256:423ac48286f88cd5e95942dce855507559d54fe7fd3a5481327424c4e6966f85",
47
47
  "run_id": "B"
48
48
  },
49
49
  {
50
50
  "blocked_fail_closed_count": 0,
51
- "content_hash": "sha256:64922c53001578fc10cf70f356b4e2399a31178f4baa4df73856267d05610f9f",
51
+ "content_hash": "sha256:900dc8d6b6514e06080c207d67e98564dccb9bb04170bf3e1dc19bbe8db90c1e",
52
52
  "executed_count": 0,
53
53
  "path": "runs/c.json",
54
- "run_artifact_hash": "sha256:b670cc60a8d3d3b6d7c1a0f5736eb56dd77396dccd2dfec6078722643ef0abbb",
54
+ "run_artifact_hash": "sha256:9a10ff578582c9476676ac454577d40e71aeb83dea128275e76d1d7e1d566f6c",
55
55
  "run_id": "C"
56
56
  },
57
57
  {
58
58
  "blocked_fail_closed_count": 0,
59
- "content_hash": "sha256:f5743ff07deb17efed1ef9c90e44f184723e99a1418b7dd3994995f2bb2f9cfb",
59
+ "content_hash": "sha256:acfe2440cd09928f73f542b39561bb5b66c77fc78cc37d56ec35b413daef32a5",
60
60
  "executed_count": 13,
61
61
  "path": "runs/d13.json",
62
- "run_artifact_hash": "sha256:daa75132d89580f45d0baf9d64eff7d697d8124de38ea1efbba25c40ea1fe69c",
62
+ "run_artifact_hash": "sha256:4ea0207eb1f9090b7a841585a268d8bef366a61d23b22bdeee0295bb52d47610",
63
63
  "run_id": "D13"
64
64
  },
65
65
  {
66
66
  "blocked_fail_closed_count": 1,
67
- "content_hash": "sha256:d7cade7c5f12edced2bf4d61835bce13dbd6c2f99fdf04d8cc0180eebde7b554",
67
+ "content_hash": "sha256:ccf02804dd2e4e41d8d79f81d4226227c6796ba5b753d0ab9724c1617839f476",
68
68
  "executed_count": 26,
69
69
  "path": "runs/d26.json",
70
- "run_artifact_hash": "sha256:fe4d1876876fe19890e7bf79bdbaff4f175abc3c60866acf77b3137c46ff1114",
70
+ "run_artifact_hash": "sha256:eb5d0df7917c119e07f82f7d7b84b4d4e84c6423753462ca7a26f9be16ebfef8",
71
71
  "run_id": "D26"
72
72
  },
73
73
  {
74
74
  "blocked_fail_closed_count": 0,
75
- "content_hash": "sha256:4baf3ed4448324cf00a8bd73e5399be833987fcfd268a4b30a5a4fe6fa7e0ece",
75
+ "content_hash": "sha256:443b278936de20e6f1645dc9eda2b1e3743c0ed58933c00794177549b256be67",
76
76
  "executed_count": 13,
77
77
  "path": "runs/e-d13.json",
78
- "run_artifact_hash": "sha256:6264276ff9749efea70ad70db981dfbbd9b7773b2565a4f7ae3701f7fc6592d3",
78
+ "run_artifact_hash": "sha256:3817bcac7b4ce7d1d0ddf06381b31b463b05790461f38de6b17e26ae2250f297",
79
79
  "run_id": "E:D13"
80
80
  },
81
81
  {
82
82
  "blocked_fail_closed_count": 1,
83
- "content_hash": "sha256:23ae4a59c10552c44705d662e5463b953c04488564e89c66b62da76a85239a02",
83
+ "content_hash": "sha256:9d5712d5baff1efe560691bc8208b25a5c90843b4ada21d1a339376a2b7395c7",
84
84
  "executed_count": 26,
85
85
  "path": "runs/e-d26.json",
86
- "run_artifact_hash": "sha256:ee892f51113cc5eed8106348998174e734e0a1bf9500078aa3d5acfb61da4293",
86
+ "run_artifact_hash": "sha256:be206b4a629bcb349da681e6eaed9f695b0c880d84963092466285c19f830ce4",
87
87
  "run_id": "E:D26"
88
88
  },
89
89
  {
90
90
  "blocked_fail_closed_count": 1,
91
- "content_hash": "sha256:377fab6c442cd6a44d5c2f2889d9399d88702e6ab0f971494dee1a1ced8a8258",
91
+ "content_hash": "sha256:805833bcc92aebb115c42de08e25c94ad4b8a98032d3375ed4301722521be02d",
92
92
  "executed_count": 26,
93
93
  "path": "runs/h_ai_reference.json",
94
- "run_artifact_hash": "sha256:f5def24331e45e8260ec19dc1731b4a0856a36be84e04b70608963b67b62ca9d",
94
+ "run_artifact_hash": "sha256:790838e3d208ed1c9c3da3210ced26871f02b7fc8b01de920804bfbdefb4c000",
95
95
  "run_id": "H_ai_reference"
96
96
  }
97
97
  ],
@@ -10,14 +10,14 @@
10
10
  "fact_id": "fund.top_ten_weight"
11
11
  }
12
12
  },
13
- "condition_id": "master_bogle.holdings_breadth_visible",
13
+ "condition_id": "master_bogle.subject_is_a_basket",
14
14
  "on_false": {
15
15
  "common_stance": "out_of_scope",
16
- "native_state": "provisional_insufficient_return_inputs"
16
+ "native_state": "provisional_not_a_basket"
17
17
  },
18
18
  "on_uncomputable": {
19
19
  "common_stance": "out_of_scope",
20
- "native_state": "provisional_insufficient_return_inputs"
20
+ "native_state": "provisional_not_a_basket"
21
21
  },
22
22
  "source_ids": [
23
23
  "proxy:1b982d2dc6b935fc0"
@@ -77,6 +77,7 @@
77
77
  }
78
78
  ],
79
79
  "native_states": [
80
+ "provisional_not_a_basket",
80
81
  "provisional_insufficient_return_inputs",
81
82
  "provisional_overpriced_market",
82
83
  "provisional_fair_expected_return",
@@ -6,9 +6,11 @@
6
6
  "provisional_fair_expected_return": "cautious",
7
7
  "provisional_insufficient_return_inputs": "out_of_scope",
8
8
  "provisional_low_cost_index_candidate": "cautious",
9
+ "provisional_not_a_basket": "cautious",
9
10
  "provisional_overpriced_market": "opposed"
10
11
  },
11
12
  "declared_state_hypotheses": [
13
+ "not_a_basket",
12
14
  "insufficient_return_inputs",
13
15
  "overpriced_market",
14
16
  "fair_expected_return",
@@ -19,6 +21,7 @@
19
21
  "formula_compilation_manifest_hash": "sha256:9eb750e5aec2e4b6ef7a7033aba119927d5a6540220d608cbe6fd90eaf255b05",
20
22
  "method_model_eligible": false,
21
23
  "native_states": [
24
+ "provisional_not_a_basket",
22
25
  "provisional_insufficient_return_inputs",
23
26
  "provisional_overpriced_market",
24
27
  "provisional_fair_expected_return",
@@ -1 +1 @@
1
- {"adjudication":{"notes":"No human review, no named-method attribution, no production effect.","reviewer_ids":[],"status":"pending"},"author":"AlphaCouncil project build specification","content_hash":"sha256:f0a1c271c11a09700d1fd65dc1b772a8d68433473478fc9d962e2078877ac494","grade":"E","known_at":"2026-07-27","locator":{"section":"master_bogle"},"public_at":"2026-07-27","published_at":"2026-07-27","retrieved_at":"2026-07-27","schema_version":1,"source_id":"proxy:1b982d2dc6b935fc0","source_kind":"derived_proxy","summary":"Project-authored provisional proxy used only to exercise the deterministic solo-test build; it is not source approval or method attribution.","supports":["solo_test_structure","deterministic_execution_only"],"title":"Provisional project-derived method hypothesis for master_bogle","url":"https://github.com/Zhao73/alphacouncil-agent/blob/main/data/persona-v3-build-specs.v1.mjs"}
1
+ {"adjudication":{"notes":"No human review, no named-method attribution, no production effect.","reviewer_ids":[],"status":"pending"},"author":"AlphaCouncil project build specification","content_hash":"sha256:21670e88406249cc764918c4137c9782bcd28419ef8f2e96077ccb5f18df121c","grade":"E","known_at":"2026-07-27","locator":{"section":"master_bogle"},"public_at":"2026-07-27","published_at":"2026-07-27","retrieved_at":"2026-07-27","schema_version":1,"source_id":"proxy:1b982d2dc6b935fc0","source_kind":"derived_proxy","summary":"Project-authored provisional proxy used only to exercise the deterministic solo-test build; it is not source approval or method attribution.","supports":["solo_test_structure","deterministic_execution_only"],"title":"Provisional project-derived method hypothesis for master_bogle","url":"https://github.com/Zhao73/alphacouncil-agent/blob/main/data/persona-v3-build-specs.v1.mjs"}
@@ -43,7 +43,7 @@
43
43
  }
44
44
  },
45
45
  "on_trigger": {
46
- "common_stance": "out_of_scope",
46
+ "common_stance": "opposed",
47
47
  "native_state": "provisional_insufficient_floor"
48
48
  },
49
49
  "on_uncomputable": {
@@ -43,7 +43,7 @@
43
43
  }
44
44
  },
45
45
  "on_trigger": {
46
- "common_stance": "out_of_scope",
46
+ "common_stance": "opposed",
47
47
  "native_state": "provisional_downside_unknown"
48
48
  },
49
49
  "on_uncomputable": {
@@ -63,7 +63,7 @@
63
63
  }
64
64
  },
65
65
  "on_trigger": {
66
- "common_stance": "out_of_scope",
66
+ "common_stance": "opposed",
67
67
  "native_state": "provisional_no_floor"
68
68
  },
69
69
  "on_uncomputable": {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * News for something that is not a company.
3
+ *
4
+ * `SOX` has no press office, files nothing, and returns nothing from a ticker news feed. Asked
5
+ * for "recent semiconductor news" the pipeline had no way to turn the symbol into that subject,
6
+ * so a basket run carried market-wide headlines and no industry at all.
7
+ *
8
+ * A basket's news identity comes from what it holds, not from a table someone maintains:
9
+ *
10
+ * - **The industry** is the modal SIC group of its largest holdings. SOXX resolves to
11
+ * semiconductors because its holdings are semiconductor registrants, and XLE resolves to
12
+ * energy for the same reason. A hand-written map would drift the first time an index
13
+ * rebalanced; this cannot.
14
+ * - **The constituents** supply their own dated company news and their own 8-K filings,
15
+ * weighted by how much of the basket each one is. A headline about 8% of the fund is worth
16
+ * more than one about 0.3% of it, and the weight travels with the item so a reader can see
17
+ * which it was.
18
+ *
19
+ * What this deliberately does NOT do is let a headline reach a seat's arithmetic. A frozen
20
+ * deterministic stance stays frozen; news informs what a seat SAYS about a stance it already
21
+ * reached, and supplies counts that are themselves dated facts. Anything else would make the
22
+ * same symbol answer differently depending on what was published that morning, which is the
23
+ * property this whole runtime exists to avoid.
24
+ */
25
+
26
+ import { applyRecencyGate, fetchFeed, filingsFeed, queryNewsFeed, tickerNewsFeed } from "./feeds.mjs";
27
+ import { sicGroupFor } from "./industry.mjs";
28
+ import { fetchSubmissions, fetchUniverse } from "./sec.mjs";
29
+
30
+ /** How many of the largest holdings are read for news. Weight decays; attention should too. */
31
+ export const NEWS_CONSTITUENT_LIMIT = 10;
32
+
33
+ /** Recency gate for a basket, in days. Wider than a single company's because a sector moves slower. */
34
+ export const BASKET_NEWS_DAYS = 14;
35
+
36
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
37
+
38
+ /**
39
+ * The industry a basket is, derived from the registrants it holds.
40
+ *
41
+ * Weighted rather than counted: an index with forty small biotech names and two enormous
42
+ * semiconductor ones is a semiconductor bet, and a headcount would say otherwise.
43
+ */
44
+ export function dominantIndustry(sicByTicker, holdings) {
45
+ return industryMix(sicByTicker, holdings)[0] || null;
46
+ }
47
+
48
+ /**
49
+ * The industries a basket actually is, largest first, until they account for most of it.
50
+ *
51
+ * A single label is right for SOXX, where semiconductors are half the fund, and wrong for a
52
+ * broad industrial basket whose largest group is eleven percent of it -- calling that one
53
+ * "electronics" would query the wrong news and state it with more confidence than the holdings
54
+ * support. Taking groups until they cover half the read weight gives an industrial fund its
55
+ * aerospace, machinery and transport news instead of one misleading query.
56
+ */
57
+ export function industryMix(sicByTicker, holdings, { cover = 0.5, max = 3 } = {}) {
58
+ const weightByGroup = new Map();
59
+ for (const holding of holdings || []) {
60
+ const ticker = String(holding?.ticker || "").toUpperCase();
61
+ const sic = sicByTicker?.get?.(ticker);
62
+ const group = sic ? sicGroupFor(sic) : null;
63
+ if (!group || !finite(holding?.weight)) continue;
64
+ const current = weightByGroup.get(group.id) || { group, weight: 0, members: 0 };
65
+ weightByGroup.set(group.id, { group, weight: current.weight + holding.weight, members: current.members + 1 });
66
+ }
67
+ if (!weightByGroup.size) return [];
68
+ const ranked = [...weightByGroup.values()].sort((left, right) => right.weight - left.weight);
69
+ const chosen = [];
70
+ let cumulative = 0;
71
+ for (const entry of ranked) {
72
+ if (chosen.length >= max || (chosen.length && cumulative >= cover)) break;
73
+ chosen.push({
74
+ id: entry.group.id,
75
+ title: entry.group.title,
76
+ weight: Number(entry.weight.toFixed(6)),
77
+ members: entry.members,
78
+ // A basket whose largest industry is a third of it is not "a semiconductor fund", and
79
+ // the share is reported so a reader can judge rather than being told a single label.
80
+ is_concentrated: entry.weight >= 0.5,
81
+ });
82
+ cumulative += entry.weight;
83
+ }
84
+ return chosen;
85
+ }
86
+
87
+ /**
88
+ * Industry and constituent news for a basket, every item carrying its own publication date.
89
+ *
90
+ * `asOf` is honoured by the recency gate, so a historical run cannot pick up items published
91
+ * after the date it claims to be reasoning at.
92
+ */
93
+ export async function fetchBasketNews(holdings, { asOf = null, days = BASKET_NEWS_DAYS, signal, limit = NEWS_CONSTITUENT_LIMIT } = {}) {
94
+ const unavailable = [];
95
+ const ranked = [...(holdings || [])]
96
+ .filter((holding) => holding?.ticker && finite(holding?.weight))
97
+ .sort((left, right) => right.weight - left.weight)
98
+ .slice(0, limit);
99
+ if (!ranked.length) return { available: false, unavailable: ["basket news: the basket published no weighted holdings"] };
100
+
101
+ let universe = null;
102
+ try {
103
+ universe = new Map((await fetchUniverse({ signal })).map((row) => [String(row.ticker).toUpperCase(), row.cik]));
104
+ } catch (error) {
105
+ unavailable.push(`basket news: SEC ticker universe unavailable (${String(error?.message || error)})`);
106
+ }
107
+
108
+ // One submissions call per constituent buys its SIC, which is what the industry is derived
109
+ // from and what a filings feed needs anyway.
110
+ const sicByTicker = new Map();
111
+ const cikByTicker = new Map();
112
+ for (const holding of ranked) {
113
+ const ticker = String(holding.ticker).toUpperCase();
114
+ const cik = universe?.get(ticker);
115
+ if (!cik) { unavailable.push(`basket news ${ticker}: no SEC registrant`); continue; }
116
+ cikByTicker.set(ticker, cik);
117
+ try {
118
+ const profile = await fetchSubmissions(cik, { signal });
119
+ if (profile?.sic) sicByTicker.set(ticker, profile.sic);
120
+ } catch (error) {
121
+ unavailable.push(`basket news ${ticker}: ${String(error?.message || error)}`);
122
+ }
123
+ }
124
+
125
+ const industries = industryMix(sicByTicker, ranked);
126
+ const industry = industries[0] || null;
127
+ const specs = [];
128
+ if (industries.length) {
129
+ // Each industry's own name is a query. Deriving them from holdings rather than from a map
130
+ // is what makes this survive a rebalance.
131
+ for (const group of industries) {
132
+ specs.push({ ...queryNewsFeed(`${group.title.en} industry`), kind: "industry", scope: "industry", industry: group.id });
133
+ }
134
+ } else {
135
+ unavailable.push("basket news: no holding resolved to a SIC group, so no industry could be named");
136
+ }
137
+ for (const holding of ranked) {
138
+ const ticker = String(holding.ticker).toUpperCase();
139
+ specs.push({ ...tickerNewsFeed(ticker), kind: "company", scope: "constituent", ticker, weight: holding.weight });
140
+ const cik = cikByTicker.get(ticker);
141
+ if (cik) specs.push({ ...filingsFeed(cik, "8-K"), kind: "filing", scope: "constituent", ticker, weight: holding.weight });
142
+ }
143
+
144
+ // Fetched per spec rather than through `fetchFeeds`, which returns a merged item list. The
145
+ // whole point here is that an item keeps which constituent it belongs to and how much of the
146
+ // basket that constituent is; a merged list has already thrown that away.
147
+ const fetched = await Promise.all(specs.map((spec) => fetchFeed(spec.url, { source: spec.source })));
148
+ const items = [];
149
+ fetched.forEach((feed, index) => {
150
+ const spec = specs[index];
151
+ if (!feed?.ok) { unavailable.push(`basket news ${spec.source}: ${feed?.reason || "unreachable"}`); return; }
152
+ for (const item of feed.items || []) {
153
+ items.push({ ...item, source: feed.source, kind: spec.kind, scope: spec.scope, ticker: spec.ticker || null, weight: spec.weight ?? null, industry: spec.industry || null });
154
+ }
155
+ });
156
+ const { included: dated } = applyRecencyGate(items, { days, asOf });
157
+
158
+ // How much of the basket actually generated dated news, and how many filed. Both are counts
159
+ // with a window, which is what makes them usable as facts rather than as impressions.
160
+ const covered = new Set(dated.filter((item) => item.ticker).map((item) => item.ticker));
161
+ const coverageWeight = ranked
162
+ .filter((holding) => covered.has(String(holding.ticker).toUpperCase()))
163
+ .reduce((sum, holding) => sum + holding.weight, 0);
164
+ const filers = new Set(dated.filter((item) => item.kind === "filing" && item.ticker).map((item) => item.ticker));
165
+
166
+ return {
167
+ available: dated.length > 0,
168
+ industry,
169
+ industries,
170
+ items: dated,
171
+ window_days: days,
172
+ constituents_read: ranked.length,
173
+ coverage_weight: Number(coverageWeight.toFixed(6)),
174
+ filing_event_count: filers.size,
175
+ filing_event_weight: Number(ranked
176
+ .filter((holding) => filers.has(String(holding.ticker).toUpperCase()))
177
+ .reduce((sum, holding) => sum + holding.weight, 0)
178
+ .toFixed(6)),
179
+ unavailable,
180
+ };
181
+ }
@@ -40,6 +40,30 @@ export function parseDailyCloses(json) {
40
40
  return clean.length ? clean : null;
41
41
  }
42
42
 
43
+ /**
44
+ * Closes paired with the session they belong to.
45
+ *
46
+ * `parseDailyCloses` drops the timestamps, which is fine for a moving average over one series
47
+ * and wrong the moment two series are compared: Korea and the United States keep different
48
+ * holidays, so aligning by array position compares a Tuesday with a Wednesday and produces a
49
+ * correlation that is confidently wrong rather than visibly broken.
50
+ */
51
+ export function parseDatedCloses(json) {
52
+ const result = json?.chart?.result?.[0];
53
+ const closes = result?.indicators?.quote?.[0]?.close;
54
+ const stamps = result?.timestamp;
55
+ if (!Array.isArray(closes) || !Array.isArray(stamps) || closes.length !== stamps.length) return null;
56
+ const rows = [];
57
+ for (let index = 0; index < closes.length; index += 1) {
58
+ const close = closes[index];
59
+ const stamp = stamps[index];
60
+ if (typeof close !== "number" || !Number.isFinite(close) || close <= 0) continue;
61
+ if (typeof stamp !== "number" || !Number.isFinite(stamp)) continue;
62
+ rows.push({ date: new Date(stamp * 1000).toISOString().slice(0, 10), close });
63
+ }
64
+ return rows.length ? rows : null;
65
+ }
66
+
43
67
  /** Simple moving average of the last `window` closes, or null when there is not enough history. */
44
68
  export function movingAverage(closes, window = BREADTH_WINDOW_DAYS) {
45
69
  if (!Array.isArray(closes) || closes.length < window) return null;
@@ -183,6 +183,9 @@ export const LIMITS = Object.freeze({
183
183
  )),
184
184
  /** Deterministic grounding is useful, but it may not hold the whole run hostage. */
185
185
  FULL_GROUNDING_MS: 30 * 1000,
186
+ // Grounding settles at its own budget and returns a partial result. This is only the
187
+ // backstop for a call that never returns at all, so it sits just beyond the budget.
188
+ GROUNDING_SETTLE_HEADROOM_MS: 5 * 1000,
186
189
  /** All eight full evidence seats launch in one wave under this per-seat cap. */
187
190
  FULL_EVIDENCE_MS: Math.max(1_000, Math.min(
188
191
  Number(process.env.ALPHACOUNCIL_FULL_EVIDENCE_MS) || 6 * 60 * 1000,
@@ -0,0 +1,180 @@
1
+ /**
2
+ * How a basket moves against other markets, and how its own sectors are moving apart.
3
+ *
4
+ * Every seat that reasons about crowding, reflexivity or position size needs to know what else
5
+ * a holding is a bet on. Dalio's own authored policy limits size by correlation; Soros needs a
6
+ * loop that runs between markets; Marks reads dispersion as where the cycle stands. None of
7
+ * them could ask, because nothing here compared one market with another.
8
+ *
9
+ * The inputs are daily closes, from the same keyless endpoint the breadth pass already uses,
10
+ * so a correlation costs one fetch per reference series and nothing per constituent.
11
+ *
12
+ * Two disciplines are enforced rather than assumed:
13
+ *
14
+ * 1. **Sessions are aligned by date, not by position.** Korea and the United States do not
15
+ * share a holiday calendar, and zipping two arrays by index silently compares a Tuesday
16
+ * with a Wednesday — which produces a correlation that is confidently wrong rather than
17
+ * obviously broken.
18
+ * 2. **A short overlap produces nothing.** A correlation over twenty paired sessions is
19
+ * noise with a decimal point on it.
20
+ */
21
+
22
+ import { LIMITS } from "./constants.mjs";
23
+ import { chartUrl, parseDatedCloses } from "./breadth.mjs";
24
+ import { fetchText } from "./quotes.mjs";
25
+
26
+ /** Windows the facts are published over. Both are stated on the fact, never implied. */
27
+ export const CORRELATION_WINDOW_DAYS = 60;
28
+
29
+ /** Below this many paired sessions a correlation is not reported at all. */
30
+ export const MIN_PAIRED_SESSIONS = 40;
31
+
32
+ /**
33
+ * Reference markets a US basket is worth being correlated against.
34
+ *
35
+ * Korea is here because it is the clearest listed read on the semiconductor and export cycle
36
+ * outside the United States: KOSPI's index weight is dominated by memory and display, so a
37
+ * semiconductor basket that has decoupled from it has decoupled from its own end demand.
38
+ */
39
+ export const REFERENCE_MARKETS = Object.freeze({
40
+ "^GSPC": { label: "S&P 500", why: "the broad US market a US basket is measured against" },
41
+ "^KS11": { label: "KOSPI", why: "Korea's main board, weighted toward memory and export manufacturing" },
42
+ "^KQ11": { label: "KOSDAQ", why: "Korea's growth board, a read on domestic risk appetite rather than exporters" },
43
+ "^SOX": { label: "PHLX Semiconductor", why: "the semiconductor cycle as a listed series" },
44
+ });
45
+
46
+ /** The eleven Select Sector SPDRs, which between them partition the S&P 500. */
47
+ export const SECTOR_SPDRS = Object.freeze(["XLK","XLF","XLE","XLV","XLI","XLY","XLP","XLU","XLB","XLRE","XLC"]);
48
+
49
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
50
+
51
+ /** Pearson correlation of two equal-length return series. */
52
+ export function correlation(left, right) {
53
+ if (!Array.isArray(left) || left.length !== right?.length || left.length < 2) return null;
54
+ const n = left.length;
55
+ const meanLeft = left.reduce((sum, value) => sum + value, 0) / n;
56
+ const meanRight = right.reduce((sum, value) => sum + value, 0) / n;
57
+ let covariance = 0;
58
+ let varianceLeft = 0;
59
+ let varianceRight = 0;
60
+ for (let index = 0; index < n; index += 1) {
61
+ const dl = left[index] - meanLeft;
62
+ const dr = right[index] - meanRight;
63
+ covariance += dl * dr;
64
+ varianceLeft += dl * dl;
65
+ varianceRight += dr * dr;
66
+ }
67
+ if (varianceLeft <= 0 || varianceRight <= 0) return null;
68
+ return covariance / Math.sqrt(varianceLeft * varianceRight);
69
+ }
70
+
71
+ /**
72
+ * Daily returns for the sessions two series actually share.
73
+ *
74
+ * Aligning on the date is the whole point. Two markets with different holidays produce arrays
75
+ * of different lengths whose positions do not correspond, and comparing them by position is
76
+ * the standard way a cross-market correlation ends up measuring nothing.
77
+ */
78
+ export function alignedReturns(left, right, windowDays = CORRELATION_WINDOW_DAYS) {
79
+ const rightByDate = new Map((right || []).map((row) => [row.date, row.close]));
80
+ const paired = [];
81
+ for (const row of left || []) {
82
+ const other = rightByDate.get(row.date);
83
+ if (finite(row.close) && finite(other)) paired.push({ date: row.date, left: row.close, right: other });
84
+ }
85
+ const window = paired.slice(-(windowDays + 1));
86
+ if (window.length < MIN_PAIRED_SESSIONS) return null;
87
+ const returns = { left: [], right: [], sessions: window.length - 1, from: window[0].date, to: window.at(-1).date };
88
+ for (let index = 1; index < window.length; index += 1) {
89
+ returns.left.push(window[index].left / window[index - 1].left - 1);
90
+ returns.right.push(window[index].right / window[index - 1].right - 1);
91
+ }
92
+ return returns;
93
+ }
94
+
95
+ async function datedCloses(symbol, signal) {
96
+ try {
97
+ return parseDatedCloses(JSON.parse(await fetchText(chartUrl(symbol), LIMITS.QUOTE_FETCH_MS, signal)));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Correlation and relative strength of one symbol against each reference market.
105
+ *
106
+ * Relative strength travels with the correlation on purpose. A correlation of 0.9 says two
107
+ * markets move together and says nothing about which one is winning; a reader given only the
108
+ * first will supply the second from imagination.
109
+ */
110
+ export async function fetchCrossMarket(symbol, { signal, references = REFERENCE_MARKETS } = {}) {
111
+ const facts = [];
112
+ const unavailable = [];
113
+ const subject = await datedCloses(symbol, signal);
114
+ if (!subject?.length) {
115
+ return { facts, unavailable: [`cross-market: no daily closes for ${symbol}`] };
116
+ }
117
+ for (const [reference, meta] of Object.entries(references)) {
118
+ if (String(reference).toUpperCase() === String(symbol).toUpperCase()) continue;
119
+ const other = await datedCloses(reference, signal);
120
+ if (!other?.length) { unavailable.push(`cross-market ${reference}: no daily closes`); continue; }
121
+ const returns = alignedReturns(subject, other);
122
+ if (!returns) {
123
+ unavailable.push(
124
+ `cross-market ${reference}: fewer than ${MIN_PAIRED_SESSIONS} sessions shared with ${symbol};`
125
+ + " the two calendars do not overlap enough to compare",
126
+ );
127
+ continue;
128
+ }
129
+ const rho = correlation(returns.left, returns.right);
130
+ if (!finite(rho)) { unavailable.push(`cross-market ${reference}: a series did not move over the window`); continue; }
131
+ const total = (series) => series.reduce((compound, value) => compound * (1 + value), 1) - 1;
132
+ facts.push({
133
+ reference,
134
+ label: meta.label,
135
+ why: meta.why,
136
+ correlation: Number(rho.toFixed(4)),
137
+ relative_return: Number((total(returns.left) - total(returns.right)).toFixed(6)),
138
+ sessions: returns.sessions,
139
+ from: returns.from,
140
+ to: returns.to,
141
+ });
142
+ }
143
+ return { facts, unavailable };
144
+ }
145
+
146
+ /**
147
+ * How far apart a set of sector baskets has travelled over the window.
148
+ *
149
+ * One number, and it is a real one: the standard deviation of sector total returns. A market
150
+ * whose sectors all return the same thing is being repriced by one factor; a market whose
151
+ * sectors have separated is being repriced by many, and those are different regimes to own.
152
+ */
153
+ export async function fetchSectorDispersion(symbols, { signal } = {}) {
154
+ const returns = [];
155
+ const unavailable = [];
156
+ let window = null;
157
+ for (const symbol of symbols || []) {
158
+ const closes = await datedCloses(symbol, signal);
159
+ const tail = (closes || []).slice(-(CORRELATION_WINDOW_DAYS + 1));
160
+ if (tail.length < MIN_PAIRED_SESSIONS) { unavailable.push(`sector dispersion ${symbol}: too few sessions`); continue; }
161
+ returns.push({ symbol, value: tail.at(-1).close / tail[0].close - 1 });
162
+ window = { from: tail[0].date, to: tail.at(-1).date, sessions: tail.length - 1 };
163
+ }
164
+ if (returns.length < 3) {
165
+ return { available: false, unavailable: [...unavailable, "sector dispersion: fewer than three sectors priced"] };
166
+ }
167
+ const mean = returns.reduce((sum, row) => sum + row.value, 0) / returns.length;
168
+ const variance = returns.reduce((sum, row) => sum + (row.value - mean) ** 2, 0) / returns.length;
169
+ const sorted = [...returns].sort((left, right) => right.value - left.value);
170
+ return {
171
+ available: true,
172
+ dispersion: Number(Math.sqrt(variance).toFixed(6)),
173
+ mean_return: Number(mean.toFixed(6)),
174
+ leader: sorted[0],
175
+ laggard: sorted.at(-1),
176
+ measured: returns.length,
177
+ ...window,
178
+ unavailable,
179
+ };
180
+ }