pp-portfolio-classifier 0.0.1

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 (37) hide show
  1. package/.github/workflows/npm-publish.yml +45 -0
  2. package/.prettierignore +6 -0
  3. package/.prettierrc.json +11 -0
  4. package/.vscode/settings.json +30 -0
  5. package/BLUEPRINT.md +28 -0
  6. package/config/default.json +1121 -0
  7. package/dist/classifier.js +195 -0
  8. package/dist/index.js +88 -0
  9. package/dist/morningstar-api.js +137 -0
  10. package/dist/types.js +2 -0
  11. package/dist/xml-helper.js +244 -0
  12. package/docs/img/autoclassified-regions.png +0 -0
  13. package/docs/img/autoclassified-sectors.png +0 -0
  14. package/docs/img/autoclassified-stock-style.png +0 -0
  15. package/docs/img/top-10-holdings.png +0 -0
  16. package/docs/taxonomy-json-templates/AllTaxonomies.json +1313 -0
  17. package/docs/taxonomy-json-templates/Asset_Type.json +45 -0
  18. package/docs/taxonomy-json-templates/Bond_Sector.json +38 -0
  19. package/docs/taxonomy-json-templates/Bond_Style.json +42 -0
  20. package/docs/taxonomy-json-templates/Country.json +214 -0
  21. package/docs/taxonomy-json-templates/Holding.json +302 -0
  22. package/docs/taxonomy-json-templates/Region.json +72 -0
  23. package/docs/taxonomy-json-templates/Region_Country.json +280 -0
  24. package/docs/taxonomy-json-templates/Stock_Sector.json +50 -0
  25. package/docs/taxonomy-json-templates/Stock_Style.json +42 -0
  26. package/docs/taxonomy-json-templates/info.txt +1 -0
  27. package/docs/taxonomy-json-templates/views.xml +45 -0
  28. package/eslint.config.mjs +65 -0
  29. package/package.json +38 -0
  30. package/readme.md +156 -0
  31. package/src/classifier.ts +242 -0
  32. package/src/index.ts +64 -0
  33. package/src/morningstar-api.ts +155 -0
  34. package/src/types.ts +28 -0
  35. package/src/xml-helper.ts +240 -0
  36. package/test/multifaktortest.xml +3983 -0
  37. package/tsconfig.json +15 -0
@@ -0,0 +1,242 @@
1
+ import config from "config";
2
+ import { filter as _filter, get } from "lodash";
3
+ import { MorningstarAPI } from "./morningstar-api";
4
+ import { PPSecurity } from "./types";
5
+ import { XMLHandler } from "./xml-helper";
6
+
7
+ interface StockConfig {
8
+ salEndpoint?: "stock/equityOverview" | "stock/companyProfile";
9
+ viewId?: string;
10
+ sourcePath?: string;
11
+ isSingleValue?: boolean;
12
+ value?: string | string[]; // For static values
13
+ mapping?: Record<string, string | string[]> | string;
14
+ }
15
+
16
+ interface TaxonomyConfig {
17
+ active: boolean;
18
+ name: string;
19
+ viewId?: string;
20
+ // Fund config
21
+ sourcePath: string;
22
+ keyField: string;
23
+ valueField: string;
24
+ filter?: Record<string, any>;
25
+ mapping: Record<string, string | string[]> | string;
26
+ // Stock config
27
+ stockConfig?: StockConfig;
28
+ }
29
+
30
+ export class Classifier {
31
+ private xmlHandler: XMLHandler;
32
+ private api: MorningstarAPI;
33
+ private taxonomiesConfig: Record<string, TaxonomyConfig>;
34
+ private globalMappings: Record<string, Record<string, string | string[]>>;
35
+
36
+ constructor(xmlHandler: XMLHandler, api: MorningstarAPI) {
37
+ this.xmlHandler = xmlHandler;
38
+ this.api = api;
39
+ this.taxonomiesConfig = config.get("taxonomies");
40
+ this.globalMappings = config.has("mappings") ? config.get("mappings") : {};
41
+ }
42
+
43
+ private getMapping(
44
+ mappingOrKey: Record<string, string | string[]> | string | undefined,
45
+ ): Record<string, string | string[]> {
46
+ if (!mappingOrKey) return {};
47
+ if (typeof mappingOrKey === "string") {
48
+ if (this.globalMappings[mappingOrKey]) {
49
+ return this.globalMappings[mappingOrKey];
50
+ }
51
+ console.warn(` [Config] Mapping key '${mappingOrKey}' not found in global mappings.`);
52
+ return {};
53
+ }
54
+ return mappingOrKey;
55
+ }
56
+
57
+ public async classifySecurity(security: PPSecurity) {
58
+ if (!security.isin && !security.isinOverride) return;
59
+ if (security.ignoreTaxonomies === true) return;
60
+
61
+ const securityInfo = await this.api.getSecurityData(security.isinOverride || security.isin!);
62
+ if (!securityInfo) {
63
+ console.log(` > No data found for ${security.name}.`);
64
+ return;
65
+ }
66
+
67
+ console.log(` > Data retrieved for ${security.name}. Type: ${securityInfo.type}. Processing taxonomies...`);
68
+
69
+ if (securityInfo.type === "Stock") {
70
+ await this.classifyStock(security, securityInfo.secid, securityInfo.data);
71
+ } else if (securityInfo.type === "Fund") {
72
+ await this.classifyFund(security, securityInfo.data);
73
+ } else {
74
+ console.error(` > Unknown security type: ${securityInfo.type}! Skipping...`);
75
+ }
76
+ }
77
+
78
+ private async classifyFund(security: PPSecurity, data: any) {
79
+ for (const [taxonomyId, taxConfig] of Object.entries(this.taxonomiesConfig)) {
80
+ if (!taxConfig.active) continue;
81
+ if (
82
+ security.ignoreTaxonomies !== undefined &&
83
+ (security.ignoreTaxonomies === true || (security.ignoreTaxonomies as string[]).includes(taxonomyId))
84
+ )
85
+ continue;
86
+ console.log(` > ${taxonomyId}`);
87
+
88
+ // 0. Fetch additional data if needed
89
+ if (taxConfig.viewId) {
90
+ const additionalData = await this.api.getSecurityData(
91
+ security.isinOverride || security.isin!,
92
+ taxConfig.viewId,
93
+ );
94
+ if (additionalData) {
95
+ Object.assign(data, additionalData.data);
96
+ }
97
+ }
98
+
99
+ // 1. Extract Data
100
+ const sourceData = get(data, taxConfig.sourcePath);
101
+ if (!sourceData) {
102
+ console.warn(` [${taxonomyId}] No data found at path '${taxConfig.sourcePath}'`);
103
+ continue;
104
+ }
105
+ if (!Array.isArray(sourceData)) {
106
+ console.warn(` [${taxonomyId}] Data at '${taxConfig.sourcePath}' is not an array.`);
107
+ continue;
108
+ }
109
+
110
+ // 2. Filter Data
111
+ // console.log(` [${taxonomyId}] Filtering ${sourceData.length} items...`);
112
+ let filteredData = sourceData;
113
+ if (taxConfig.filter) {
114
+ filteredData = _filter(sourceData, taxConfig.filter);
115
+ }
116
+
117
+ if (filteredData.length === 0) {
118
+ console.log(` [${taxonomyId}] No items match the filter.`);
119
+ continue;
120
+ }
121
+
122
+ // 2.1 Extract BreakdownValues (Flattening)
123
+ // The data we want is usually nested in a 'BreakdownValues' array inside the filtered items
124
+ let itemsToProcess: any[] = [];
125
+ for (const group of filteredData) {
126
+ if (group.BreakdownValues && Array.isArray(group.BreakdownValues)) {
127
+ itemsToProcess.push(...group.BreakdownValues);
128
+ } else {
129
+ itemsToProcess.push(group);
130
+ }
131
+ }
132
+
133
+ // 3. Map and Assign
134
+ // console.log(` [${taxonomyId}] Assigning ${itemsToProcess.length} items...`);
135
+ const assignments: { path: string[]; weight: number }[] = [];
136
+ const mapping = this.getMapping(taxConfig.mapping);
137
+ for (const item of itemsToProcess) {
138
+ const key = item[taxConfig.keyField];
139
+ const value = parseFloat(item[taxConfig.valueField]);
140
+
141
+ if (key && value >= 0.01) {
142
+ if (!Object.keys(mapping).length) {
143
+ assignments.push({ path: [key], weight: value * 100 });
144
+ } else if (key in mapping) {
145
+ const targetClass = mapping[key];
146
+ if (targetClass) {
147
+ const path = Array.isArray(targetClass) ? targetClass : [targetClass];
148
+ assignments.push({ path, weight: value * 100 });
149
+ console.log(` [${taxonomyId}] Mapped '${key}' to '${path.join(" > ")}' (${value.toFixed(2)}%)`);
150
+ }
151
+ } else {
152
+ console.log(` [${taxonomyId}] Unmapped key: '${key}' (Value: ${value})`);
153
+ }
154
+ }
155
+ }
156
+ this.xmlHandler.updateSecurityAssignments(taxConfig.name || taxonomyId, security.uuid, assignments);
157
+ }
158
+ }
159
+
160
+ private async classifyStock(security: PPSecurity, secid: string | null, data: any) {
161
+ if (!secid && !data) {
162
+ console.warn(` > Cannot classify stock ${security.name} without data.`);
163
+ return;
164
+ }
165
+
166
+ for (const [taxonomyId, taxConfig] of Object.entries(this.taxonomiesConfig)) {
167
+ if (!taxConfig.active) continue;
168
+ if (
169
+ security.ignoreTaxonomies !== undefined &&
170
+ (security.ignoreTaxonomies === true || (security.ignoreTaxonomies as string[]).includes(taxonomyId))
171
+ )
172
+ continue;
173
+ if (!taxConfig.stockConfig) continue;
174
+ console.log(` > ${taxonomyId}`);
175
+
176
+ const stockConfig = taxConfig.stockConfig;
177
+
178
+ if (stockConfig.isSingleValue && stockConfig.value) {
179
+ // Case 1: Static value (e.g., Asset Type is always 'Stocks')
180
+ const path = Array.isArray(stockConfig.value) ? stockConfig.value : [stockConfig.value];
181
+ this.xmlHandler.updateSecurityAssignments(taxConfig.name || taxonomyId, security.uuid, [
182
+ { path, weight: 10000 },
183
+ ]); // 100% weight
184
+ } else if (stockConfig.sourcePath) {
185
+ // Case 2: Value from Data (SAL or Initial)
186
+ let key: any;
187
+
188
+ if (stockConfig.salEndpoint) {
189
+ if (!secid) {
190
+ console.warn(` [${taxonomyId}] Cannot fetch SAL data without secid.`);
191
+ continue;
192
+ }
193
+ const salData = await this.api.getSalData(secid, stockConfig.salEndpoint);
194
+ if (!salData) {
195
+ console.warn(` [${taxonomyId}] No SAL data retrieved.`);
196
+ continue;
197
+ }
198
+ key = get(salData, stockConfig.sourcePath);
199
+ } else if (stockConfig.viewId) {
200
+ const id = security.isinOverride || security.isin;
201
+ if (!id) {
202
+ console.warn(` [${taxonomyId}] Cannot fetch view data without ID.`);
203
+ continue;
204
+ }
205
+
206
+ const viewResult = await this.api.getSecurityData(id, stockConfig.viewId);
207
+ // console.debug(security, viewResult);
208
+ if (viewResult && viewResult.data) {
209
+ key = get(viewResult.data, stockConfig.sourcePath);
210
+ // console.debug(security.name, viewResult.data.QuantitativeFairValue, stockConfig.sourcePath, key);
211
+ } else {
212
+ console.warn(` [${taxonomyId}] No data retrieved for view '${stockConfig.viewId}'.`);
213
+ continue;
214
+ }
215
+ } else {
216
+ // Use data from initial request
217
+ key = get(data, stockConfig.sourcePath);
218
+ }
219
+
220
+ const mapping = this.getMapping(stockConfig.mapping);
221
+ if (key) {
222
+ // console.debug(security.name, key, mapping[key]);
223
+ if (!Object.keys(mapping).length) {
224
+ this.xmlHandler.updateSecurityAssignments(taxConfig.name || taxonomyId, security.uuid, [
225
+ { path: [key], weight: 10000 },
226
+ ]); // 100% weight
227
+ } else if (key in mapping) {
228
+ const targetClass = mapping[key];
229
+ if (targetClass) {
230
+ const path = Array.isArray(targetClass) ? targetClass : [targetClass];
231
+ this.xmlHandler.updateSecurityAssignments(taxConfig.name || taxonomyId, security.uuid, [
232
+ { path, weight: 10000 },
233
+ ]); // 100% weight
234
+ }
235
+ } else {
236
+ console.warn(` [${taxonomyId}] Unmapped stock value '${key}'.`);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ import * as path from "path";
2
+ import { Classifier } from "./classifier";
3
+ import { MorningstarAPI } from "./morningstar-api";
4
+ import { XMLHandler } from "./xml-helper";
5
+
6
+ async function main() {
7
+ const inputFile = process.argv[2];
8
+
9
+ if (!inputFile) {
10
+ console.error("Usage: npm start <input_file.xml> [output_file.xml]");
11
+ process.exit(1);
12
+ }
13
+
14
+ const outputFile =
15
+ process.argv[3] ||
16
+ path.join(path.dirname(inputFile), path.basename(inputFile, path.extname(inputFile)) + ".categorized.xml");
17
+
18
+ // 1. Init modules
19
+ const xmlHandler = new XMLHandler();
20
+ const api = new MorningstarAPI();
21
+ const classifier = new Classifier(xmlHandler, api);
22
+
23
+ // 2. Load XML
24
+ try {
25
+ xmlHandler.load(inputFile);
26
+ } catch (e) {
27
+ console.error("Error reading XML:", e);
28
+ process.exit(1);
29
+ }
30
+
31
+ // 3. Get Securities
32
+ const securities = xmlHandler.getSecurities();
33
+ console.log(`Found ${securities.length} securities.`);
34
+
35
+ // 4. Process loop
36
+ let processedCount = 0;
37
+
38
+ for (const sec of securities) {
39
+ // console.log("Processing", sec);
40
+ if (sec.isRetired) continue;
41
+ if (!sec.isin && !sec.isinOverride) {
42
+ console.log(`Skipping ${sec.name} (no ISIN).`);
43
+ continue;
44
+ }
45
+
46
+ console.log(`Processing ${sec.name} (${sec.isin})...`);
47
+
48
+ try {
49
+ await classifier.classifySecurity(sec);
50
+ } catch (err) {
51
+ console.error(` Error processing ${sec.isin}:`, err);
52
+ }
53
+
54
+ processedCount++;
55
+ // Petit délai pour être gentil avec l'API
56
+ await new Promise((resolve) => setTimeout(resolve, 500));
57
+ }
58
+
59
+ // 5. Save XML (Test de compatibilité: on sauvegarde sans modif pour voir si PP l'ouvre)
60
+ xmlHandler.save(outputFile);
61
+ console.log("Done. Try opening the output file in Portfolio Performance.");
62
+ }
63
+
64
+ main();
@@ -0,0 +1,155 @@
1
+ import axios, { AxiosError } from "axios";
2
+ import config from "config";
3
+
4
+ /*
5
+ Useful Morningstar Postman workspace for exploring the API
6
+ https://www.postman.com/dynamic-services-morningstar-com/morningstar-direct-web-services/request/pnun7vq/investment-details-mutual-fund-snapshot-view
7
+ */
8
+
9
+ // Define a type for the security data response
10
+ interface SecurityDataResponse {
11
+ type: "Fund" | "Stock" | "Unknown";
12
+ data: any;
13
+ secid: string | null;
14
+ }
15
+
16
+ export class MorningstarAPI {
17
+ private bearerToken: string = "";
18
+ private domain: string;
19
+ private baseUrl: string;
20
+ private salBaseUrl: string;
21
+ private viewId: string;
22
+
23
+ constructor() {
24
+ this.domain = config.get("morningstar.domain");
25
+ this.baseUrl = config.get("morningstar.baseUrl");
26
+ this.salBaseUrl = config.get("morningstar.salBaseUrl");
27
+ this.viewId = config.get("morningstar.viewId");
28
+ }
29
+
30
+ private async getBearerToken(): Promise<string> {
31
+ if (this.bearerToken) return this.bearerToken;
32
+
33
+ console.log("Fetching Morningstar Bearer Token...");
34
+ const url = `https://www.morningstar.${this.domain}/Common/funds/snapshot/PortfolioSAL.aspx`;
35
+
36
+ try {
37
+ const response = await axios.get(url);
38
+ const tokenRegex = /const maasToken \=\s\"(.+)\"/;
39
+ const match = response.data.match(tokenRegex);
40
+
41
+ if (match && match[1]) {
42
+ this.bearerToken = match[1];
43
+ // console.debug("Got Bearer Token:", this.bearerToken);
44
+ return this.bearerToken;
45
+ } else {
46
+ throw new Error("Token regex failed");
47
+ }
48
+ } catch (error) {
49
+ console.error("Failed to get Bearer Token", error);
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Récupère des données complémentaires via une vue spécifique (ex: 'snapshot').
56
+ * Peut être utilisé avec un ISIN ou un SecId.
57
+ */
58
+ async getSecurityData(id: string, viewId?: string, idType: "ISIN" | "SecId" = "ISIN"): Promise<any> {
59
+ const token = await this.getBearerToken();
60
+
61
+ const url = `${this.baseUrl}/securities/${id}`;
62
+ const params = {
63
+ idtype: idType,
64
+ viewid: viewId || this.viewId,
65
+ currencyId: "EUR",
66
+ responseViewFormat: "json",
67
+ languageId: "en-UK",
68
+ };
69
+ const headers = { Authorization: `Bearer ${token}`, accept: "*/*" };
70
+
71
+ try {
72
+ const response = await axios.get(url, { params, headers });
73
+ if (response.data && response.data.length > 0) {
74
+ const securityInfo = response.data[0];
75
+ // if (isin == "NL0011585146") console.debug(isin, securityInfo);
76
+ return {
77
+ type: securityInfo.Type as "Fund" | "Stock",
78
+ data: securityInfo,
79
+ secid: securityInfo.Id || id, // Fallback to ISIN if SecId is missing (SAL API often accepts ISIN)
80
+ };
81
+ }
82
+ // If response is empty (200 OK but no data), try fallback
83
+ console.log(` > ecint API returned empty for ${id}, trying website search fallback...`);
84
+ return this.findSecidFromWebsite(id);
85
+ } catch (error) {
86
+ if ((error as AxiosError).response?.status === 401) {
87
+ // This can happen for stocks not covered by this endpoint. Try the fallback.
88
+ console.log(` > ecint API failed for ${id}, trying website search fallback...`);
89
+ return this.findSecidFromWebsite(id);
90
+ }
91
+ console.warn(` Warning: Could not fetch data for ${id} from ecint API. Error: ${(error as Error).message}`);
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Récupère des données détaillées pour les actions via l'API SAL.
98
+ */
99
+ async getSalData(secid: string, endpoint: "stock/equityOverview" | "stock/companyProfile"): Promise<any> {
100
+ const token = await this.getBearerToken();
101
+
102
+ let url = `${this.salBaseUrl}/${endpoint}`;
103
+ let params: Record<string, string> = {
104
+ languageId: "en-UK",
105
+ locale: "en",
106
+ };
107
+ const headers = { Authorization: `Bearer ${token}`, accept: "*/*" };
108
+
109
+ if (endpoint === "stock/equityOverview") {
110
+ url += `/${secid}/data`;
111
+ params.version = "4.65.0";
112
+ } else {
113
+ // companyProfile
114
+ url += `/${secid}`;
115
+ }
116
+
117
+ try {
118
+ const response = await axios.get(url, { params, headers });
119
+ return response.data;
120
+ } catch (error) {
121
+ console.warn(` Warning: Could not fetch SAL data for secid ${secid} from endpoint ${endpoint}.`);
122
+ console.debug(url, params);
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Fallback: Trouve le SecId d'un titre en cherchant sur le site de Morningstar.
129
+ * C'est utile pour les actions qui ne sont pas dans l'API ecint.
130
+ */
131
+ private async findSecidFromWebsite(isin: string): Promise<SecurityDataResponse | null> {
132
+ const url = `https://global.morningstar.com/api/v1/${this.domain}/search/securities`;
133
+ const params = { query: `((isin ~= "${isin}"))` };
134
+ const headers = { "user-agent": "Mozilla/5.0" };
135
+
136
+ try {
137
+ const response = await axios.get(url, { params, headers });
138
+ const results = response.data?.results;
139
+ if (results && results.length > 0) {
140
+ const secid = results[0].securityID;
141
+ const universe = results[0].universe;
142
+ const type = universe === "EQ" ? "Stock" : "Unknown";
143
+
144
+ if (type === "Stock") {
145
+ console.log(` > Found secid '${secid}' for stock ${isin} via fallback.`);
146
+ return { type: "Stock", data: null, secid: secid };
147
+ }
148
+ }
149
+ return null;
150
+ } catch (error) {
151
+ console.warn(` Warning: Fallback search for ${isin} failed.`);
152
+ return null;
153
+ }
154
+ }
155
+ }
package/src/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ export interface PPSecurity {
2
+ uuid: string;
3
+ name: string;
4
+ isin?: string;
5
+ tickerSymbol?: string;
6
+ note?: string;
7
+ isRetired?: string; // Souvent "true" ou "false" en string dans le XML
8
+ isinOverride?: string;
9
+ ignoreTaxonomies?: string[] | boolean;
10
+ }
11
+
12
+ export interface PPTaxonomy {
13
+ id: string;
14
+ name: string;
15
+ root: {
16
+ id: string;
17
+ name: string;
18
+ children?: any;
19
+ assignments?: any;
20
+ };
21
+ }
22
+
23
+ export interface MorningstarData {
24
+ isin: string;
25
+ name: string;
26
+ type: "Fund" | "Stock" | "Unknown";
27
+ data: any; // Le payload JSON complet de l'API
28
+ }