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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +82 -0
- package/README.md +24 -15
- package/README.zh-CN.md +18 -10
- package/data/authored/core-seats.mjs +11 -7
- package/data/authored/quant-seats.mjs +2 -2
- package/data/authored/value-seats.mjs +4 -4
- package/data/build-profile.v1.json +1 -1
- package/data/persona-v3-build-specs.v1.mjs +1 -1
- package/docs/plans/v1.1-say-something-worth-reading.md +116 -0
- package/knowledge/ai-assisted-solo/experiments/runs/a.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/b.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/c.json +2 -2
- package/knowledge/ai-assisted-solo/experiments/runs/d13.json +15 -15
- package/knowledge/ai-assisted-solo/experiments/runs/d26.json +27 -27
- package/knowledge/ai-assisted-solo/experiments/runs/e-d13.json +18 -18
- package/knowledge/ai-assisted-solo/experiments/runs/e-d26.json +30 -30
- package/knowledge/ai-assisted-solo/experiments/runs/h_ai_reference.json +31 -31
- package/knowledge/ai-assisted-solo/experiments/simulation-input.json +9 -9
- package/knowledge/ai-assisted-solo/experiments/simulation-manifest.json +19 -19
- package/knowledge/solo-test/masters/master_bogle/decision_policy.json +4 -3
- package/knowledge/solo-test/masters/master_bogle/provisional-index.json +3 -0
- package/knowledge/solo-test/masters/master_bogle/sources.jsonl +1 -1
- package/knowledge/solo-test/masters/master_graham/decision_policy.json +1 -1
- package/knowledge/solo-test/masters/master_klarman/decision_policy.json +1 -1
- package/knowledge/solo-test/masters/master_pabrai/decision_policy.json +1 -1
- package/mcp/lib/basket-news.mjs +181 -0
- package/mcp/lib/breadth.mjs +24 -0
- package/mcp/lib/constants.mjs +3 -0
- package/mcp/lib/cross-market.mjs +180 -0
- package/mcp/lib/fundamentals.mjs +11 -0
- package/mcp/lib/grounding.mjs +101 -3
- package/mcp/lib/industry.mjs +23 -2
- package/mcp/lib/insider-ownership.mjs +23 -12
- package/mcp/lib/instrument-facts.mjs +3 -0
- package/mcp/lib/markdown.mjs +41 -8
- package/mcp/lib/orchestrator.mjs +10 -2
- package/mcp/lib/personas/engine.mjs +66 -9
- package/mcp/lib/personas-v3/deterministic-executor.mjs +39 -2
- package/mcp/lib/personas-v3/grounding-adapter.mjs +143 -0
- package/mcp/lib/personas-v3/runtime.mjs +9 -2
- package/mcp/lib/screen.mjs +27 -1
- package/mcp/lib/sec.mjs +80 -17
- package/mcp/lib/voice-from-decision.mjs +211 -0
- package/package.json +1 -1
- 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:
|
|
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":
|
|
34
|
+
"byte_length": 27650,
|
|
35
35
|
"path": "mcp/lib/personas-v3/runtime.mjs",
|
|
36
|
-
"physical_file_hash": "sha256:
|
|
36
|
+
"physical_file_hash": "sha256:aa32cf59eaec9eeb9583e44b76b31fac8fb8661e49b0b19da6de0f9b4ca5f568"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
|
-
"byte_length":
|
|
39
|
+
"byte_length": 51789,
|
|
40
40
|
"path": "mcp/lib/personas-v3/deterministic-executor.mjs",
|
|
41
|
-
"physical_file_hash": "sha256:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
10
|
-
"input_hash": "sha256:
|
|
9
|
+
"content_hash": "sha256:6340496a97245ce4bef30d91fdd7007e1128d7324ab28f55f8ff884a221a1363",
|
|
10
|
+
"input_hash": "sha256:cc4ffaa54c9477000383adc82ba43800c3725c331638f2f8f63cad15582a128f",
|
|
11
11
|
"path": "simulation-input.json"
|
|
12
12
|
},
|
|
13
|
-
"manifest_hash": "sha256:
|
|
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:
|
|
35
|
+
"content_hash": "sha256:6370e7f572f4ebcdec74b2424cd4a8b7f2867bd788902d38319b09d7c966ce52",
|
|
36
36
|
"executed_count": 1,
|
|
37
37
|
"path": "runs/a.json",
|
|
38
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
43
|
+
"content_hash": "sha256:82ad061ab98755a8f62ac2007ef245f28c711f71bbb9e99d27d02c8a61a52451",
|
|
44
44
|
"executed_count": 0,
|
|
45
45
|
"path": "runs/b.json",
|
|
46
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
51
|
+
"content_hash": "sha256:900dc8d6b6514e06080c207d67e98564dccb9bb04170bf3e1dc19bbe8db90c1e",
|
|
52
52
|
"executed_count": 0,
|
|
53
53
|
"path": "runs/c.json",
|
|
54
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
59
|
+
"content_hash": "sha256:acfe2440cd09928f73f542b39561bb5b66c77fc78cc37d56ec35b413daef32a5",
|
|
60
60
|
"executed_count": 13,
|
|
61
61
|
"path": "runs/d13.json",
|
|
62
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
67
|
+
"content_hash": "sha256:ccf02804dd2e4e41d8d79f81d4226227c6796ba5b753d0ab9724c1617839f476",
|
|
68
68
|
"executed_count": 26,
|
|
69
69
|
"path": "runs/d26.json",
|
|
70
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
75
|
+
"content_hash": "sha256:443b278936de20e6f1645dc9eda2b1e3743c0ed58933c00794177549b256be67",
|
|
76
76
|
"executed_count": 13,
|
|
77
77
|
"path": "runs/e-d13.json",
|
|
78
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
83
|
+
"content_hash": "sha256:9d5712d5baff1efe560691bc8208b25a5c90843b4ada21d1a339376a2b7395c7",
|
|
84
84
|
"executed_count": 26,
|
|
85
85
|
"path": "runs/e-d26.json",
|
|
86
|
-
"run_artifact_hash": "sha256:
|
|
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:
|
|
91
|
+
"content_hash": "sha256:805833bcc92aebb115c42de08e25c94ad4b8a98032d3375ed4301722521be02d",
|
|
92
92
|
"executed_count": 26,
|
|
93
93
|
"path": "runs/h_ai_reference.json",
|
|
94
|
-
"run_artifact_hash": "sha256:
|
|
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.
|
|
13
|
+
"condition_id": "master_bogle.subject_is_a_basket",
|
|
14
14
|
"on_false": {
|
|
15
15
|
"common_stance": "out_of_scope",
|
|
16
|
-
"native_state": "
|
|
16
|
+
"native_state": "provisional_not_a_basket"
|
|
17
17
|
},
|
|
18
18
|
"on_uncomputable": {
|
|
19
19
|
"common_stance": "out_of_scope",
|
|
20
|
-
"native_state": "
|
|
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:
|
|
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"}
|
|
@@ -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
|
+
}
|
package/mcp/lib/breadth.mjs
CHANGED
|
@@ -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;
|
package/mcp/lib/constants.mjs
CHANGED
|
@@ -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
|
+
}
|