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.
- package/.github/workflows/npm-publish.yml +45 -0
- package/.prettierignore +6 -0
- package/.prettierrc.json +11 -0
- package/.vscode/settings.json +30 -0
- package/BLUEPRINT.md +28 -0
- package/config/default.json +1121 -0
- package/dist/classifier.js +195 -0
- package/dist/index.js +88 -0
- package/dist/morningstar-api.js +137 -0
- package/dist/types.js +2 -0
- package/dist/xml-helper.js +244 -0
- package/docs/img/autoclassified-regions.png +0 -0
- package/docs/img/autoclassified-sectors.png +0 -0
- package/docs/img/autoclassified-stock-style.png +0 -0
- package/docs/img/top-10-holdings.png +0 -0
- package/docs/taxonomy-json-templates/AllTaxonomies.json +1313 -0
- package/docs/taxonomy-json-templates/Asset_Type.json +45 -0
- package/docs/taxonomy-json-templates/Bond_Sector.json +38 -0
- package/docs/taxonomy-json-templates/Bond_Style.json +42 -0
- package/docs/taxonomy-json-templates/Country.json +214 -0
- package/docs/taxonomy-json-templates/Holding.json +302 -0
- package/docs/taxonomy-json-templates/Region.json +72 -0
- package/docs/taxonomy-json-templates/Region_Country.json +280 -0
- package/docs/taxonomy-json-templates/Stock_Sector.json +50 -0
- package/docs/taxonomy-json-templates/Stock_Style.json +42 -0
- package/docs/taxonomy-json-templates/info.txt +1 -0
- package/docs/taxonomy-json-templates/views.xml +45 -0
- package/eslint.config.mjs +65 -0
- package/package.json +38 -0
- package/readme.md +156 -0
- package/src/classifier.ts +242 -0
- package/src/index.ts +64 -0
- package/src/morningstar-api.ts +155 -0
- package/src/types.ts +28 -0
- package/src/xml-helper.ts +240 -0
- package/test/multifaktortest.xml +3983 -0
- 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
|
+
}
|