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,240 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { XMLBuilder, XMLParser } from "fast-xml-parser";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import { PPSecurity } from "./types";
|
|
5
|
+
|
|
6
|
+
const options = {
|
|
7
|
+
ignoreAttributes: false,
|
|
8
|
+
attributeNamePrefix: "@_",
|
|
9
|
+
format: true,
|
|
10
|
+
indentBy: " ",
|
|
11
|
+
suppressEmptyNode: true,
|
|
12
|
+
isArray: (name: string, jpath: string, isLeafNode: boolean, isAttribute: boolean) => {
|
|
13
|
+
// Force ces éléments à être des tableaux même s'il n'y en a qu'un seul
|
|
14
|
+
const arrayTags = [
|
|
15
|
+
"securities.security",
|
|
16
|
+
"taxonomies.taxonomy",
|
|
17
|
+
"assignments.assignment",
|
|
18
|
+
"children.classification",
|
|
19
|
+
];
|
|
20
|
+
if (arrayTags.some((tag) => jpath.endsWith(tag))) return true;
|
|
21
|
+
return false;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export class XMLHandler {
|
|
26
|
+
private parser: XMLParser;
|
|
27
|
+
private builder: XMLBuilder;
|
|
28
|
+
private xmlData: any;
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
this.parser = new XMLParser(options);
|
|
32
|
+
this.builder = new XMLBuilder(options);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
load(filepath: string): void {
|
|
36
|
+
console.log(`Loading XML file: ${filepath}`);
|
|
37
|
+
const fileContent = fs.readFileSync(filepath, "utf-8");
|
|
38
|
+
this.xmlData = this.parser.parse(fileContent);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
save(filepath: string): void {
|
|
42
|
+
console.log(`Saving XML file to: ${filepath}`);
|
|
43
|
+
const xmlContent = this.builder.build(this.xmlData);
|
|
44
|
+
// Hack pour ajouter l'en-tête XML standard si manquant (PP aime bien l'encodage UTF-8 explicite)
|
|
45
|
+
const finalXml = `<?xml version="1.0" encoding="UTF-8"?>\n${xmlContent}`;
|
|
46
|
+
fs.writeFileSync(filepath, finalXml, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getSecurities(): PPSecurity[] {
|
|
50
|
+
if (!this.xmlData?.client?.securities?.security) return [];
|
|
51
|
+
|
|
52
|
+
return this.xmlData.client.securities.security.map((sec: any) => {
|
|
53
|
+
const security: PPSecurity = {
|
|
54
|
+
uuid: sec.uuid,
|
|
55
|
+
name: sec.name,
|
|
56
|
+
isin: sec.isin,
|
|
57
|
+
note: sec.note,
|
|
58
|
+
isRetired: sec.isRetired,
|
|
59
|
+
isinOverride: undefined,
|
|
60
|
+
ignoreTaxonomies: undefined,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (sec.note) {
|
|
64
|
+
const isinMatch = sec.note.match(/#PPC:\[ISIN2=([A-Z0-9]{12})\]/);
|
|
65
|
+
if (isinMatch) security.isinOverride = isinMatch[1];
|
|
66
|
+
|
|
67
|
+
const ignoreMatch = sec.note.match(/#PPC:\[ignore(?:=([^\]]+))?\]/);
|
|
68
|
+
if (ignoreMatch) {
|
|
69
|
+
security.ignoreTaxonomies = ignoreMatch[1] ? ignoreMatch[1].split(",").map((s: string) => s.trim()) : true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// console.debug(security);
|
|
73
|
+
return security;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Méthode utilitaire pour vérifier si le XML est chargé
|
|
78
|
+
isLoaded(): boolean {
|
|
79
|
+
return !!this.xmlData;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Finds or creates a taxonomy by name
|
|
84
|
+
*/
|
|
85
|
+
getTaxonomy(name: string): any {
|
|
86
|
+
if (!this.xmlData.client.taxonomies) {
|
|
87
|
+
this.xmlData.client.taxonomies = { taxonomy: [] };
|
|
88
|
+
}
|
|
89
|
+
let tax = this.xmlData.client.taxonomies.taxonomy.find((t: any) => t.name === name);
|
|
90
|
+
if (!tax) {
|
|
91
|
+
tax = {
|
|
92
|
+
id: randomUUID(),
|
|
93
|
+
name: name,
|
|
94
|
+
root: {
|
|
95
|
+
id: randomUUID(),
|
|
96
|
+
name: name,
|
|
97
|
+
color: "#89afee",
|
|
98
|
+
children: { classification: [] },
|
|
99
|
+
assignments: { assignment: [] },
|
|
100
|
+
weight: 10000,
|
|
101
|
+
rank: 0,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
this.xmlData.client.taxonomies.taxonomy.push(tax);
|
|
105
|
+
}
|
|
106
|
+
return tax;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Assigns a security to a specific classification path within a taxonomy
|
|
111
|
+
*/
|
|
112
|
+
assignSecurityToTaxonomy(taxonomyName: string, path: string[], securityUuid: string, weight: number) {
|
|
113
|
+
const tax = this.getTaxonomy(taxonomyName);
|
|
114
|
+
|
|
115
|
+
// 1. Remove existing assignments for this security in this taxonomy to avoid duplicates
|
|
116
|
+
const securityIndex = this.getSecurityIndex(securityUuid);
|
|
117
|
+
if (securityIndex === -1) {
|
|
118
|
+
console.error(` [XML] Error: Security UUID ${securityUuid} not found in XML structure.`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// console.log(` [XML] Updating '${taxonomyName}' -> ${path.join(' > ')}: ${(weight/100).toFixed(2)}%`);
|
|
123
|
+
|
|
124
|
+
// 2. Find or create the target classification node
|
|
125
|
+
const targetNode = this.ensureClassificationPath(tax.root, path);
|
|
126
|
+
|
|
127
|
+
// 3. Add the new assignment
|
|
128
|
+
if (!targetNode.assignments) targetNode.assignments = { assignment: [] };
|
|
129
|
+
if (!targetNode.assignments.assignment) targetNode.assignments.assignment = [];
|
|
130
|
+
|
|
131
|
+
// Calculate depth for relative reference (Root is depth 0)
|
|
132
|
+
const depth = path.length;
|
|
133
|
+
const securityRef = this.getSecurityReference(securityIndex, depth);
|
|
134
|
+
|
|
135
|
+
// Check if assignment already exists to avoid duplicates in the same node
|
|
136
|
+
const existingAssignmentIndex = targetNode.assignments.assignment.findIndex(
|
|
137
|
+
(a: any) => a.investmentVehicle && a.investmentVehicle["@_reference"] === securityRef,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const newAssignment = {
|
|
141
|
+
investmentVehicle: {
|
|
142
|
+
"@_class": "security",
|
|
143
|
+
"@_reference": securityRef,
|
|
144
|
+
},
|
|
145
|
+
weight: Math.round(weight),
|
|
146
|
+
rank: 0,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (existingAssignmentIndex !== -1) {
|
|
150
|
+
targetNode.assignments.assignment[existingAssignmentIndex] = newAssignment;
|
|
151
|
+
} else {
|
|
152
|
+
targetNode.assignments.assignment.push(newAssignment);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Removes all assignments for a specific security in a given taxonomy.
|
|
158
|
+
*/
|
|
159
|
+
removeSecurityFromTaxonomy(taxonomyName: string, securityUuid: string) {
|
|
160
|
+
const tax = this.getTaxonomy(taxonomyName);
|
|
161
|
+
const securityIndex = this.getSecurityIndex(securityUuid);
|
|
162
|
+
|
|
163
|
+
if (securityIndex === -1) return;
|
|
164
|
+
|
|
165
|
+
const removeRecursive = (node: any, depth: number) => {
|
|
166
|
+
if (node.assignments && node.assignments.assignment) {
|
|
167
|
+
const securityRef = this.getSecurityReference(securityIndex, depth);
|
|
168
|
+
node.assignments.assignment = node.assignments.assignment.filter(
|
|
169
|
+
(a: any) => !a.investmentVehicle || a.investmentVehicle["@_reference"] !== securityRef,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (node.children && node.children.classification) {
|
|
174
|
+
node.children.classification.forEach((child: any) => removeRecursive(child, depth + 1));
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
removeRecursive(tax.root, 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Updates assignments for a security in a taxonomy.
|
|
183
|
+
* If assignments are provided, it clears existing ones first .
|
|
184
|
+
* If no assignments are provided, it leaves existing ones untouched.
|
|
185
|
+
*/
|
|
186
|
+
updateSecurityAssignments(
|
|
187
|
+
taxonomyName: string,
|
|
188
|
+
securityUuid: string,
|
|
189
|
+
assignments: { path: string[]; weight: number }[],
|
|
190
|
+
) {
|
|
191
|
+
if (!assignments || assignments.length === 0) return;
|
|
192
|
+
|
|
193
|
+
this.removeSecurityFromTaxonomy(taxonomyName, securityUuid);
|
|
194
|
+
|
|
195
|
+
for (const assignment of assignments) {
|
|
196
|
+
this.assignSecurityToTaxonomy(taxonomyName, assignment.path, securityUuid, assignment.weight);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private ensureClassificationPath(root: any, path: string[]): any {
|
|
201
|
+
let current = root;
|
|
202
|
+
for (const segment of path) {
|
|
203
|
+
if (!current.children) current.children = { classification: [] };
|
|
204
|
+
if (!current.children.classification) current.children.classification = [];
|
|
205
|
+
|
|
206
|
+
let nextNode = current.children.classification.find((c: any) => c.name === segment);
|
|
207
|
+
if (!nextNode) {
|
|
208
|
+
nextNode = {
|
|
209
|
+
id: randomUUID(),
|
|
210
|
+
name: segment,
|
|
211
|
+
color: "#89afee",
|
|
212
|
+
parent: { "@_reference": `../../..` },
|
|
213
|
+
children: { classification: [] },
|
|
214
|
+
assignments: { assignment: [] },
|
|
215
|
+
weight: 0,
|
|
216
|
+
rank: 0,
|
|
217
|
+
};
|
|
218
|
+
current.children.classification.push(nextNode);
|
|
219
|
+
}
|
|
220
|
+
current = nextNode;
|
|
221
|
+
}
|
|
222
|
+
return current;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private getSecurityIndex(uuid: string): number {
|
|
226
|
+
if (!this.xmlData?.client?.securities?.security) return -1;
|
|
227
|
+
return this.xmlData.client.securities.security.findIndex((s: any) => s.uuid === uuid);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private getSecurityReference(index: number, depth: number): string {
|
|
231
|
+
// Base depth calculation:
|
|
232
|
+
// Taxonomy(Root) -> Children -> Classif -> Assignments -> Assignment -> InvVehicle
|
|
233
|
+
// 6 levels up to Client for depth 0 (direct child of root)
|
|
234
|
+
// For each nesting level, add 2 levels (Children + Classification)
|
|
235
|
+
const levelsUp = 6 + depth * 2;
|
|
236
|
+
const prefix = "../".repeat(levelsUp);
|
|
237
|
+
const suffix = index === 0 ? "securities/security" : `securities/security[${index + 1}]`;
|
|
238
|
+
return `${prefix}${suffix}`;
|
|
239
|
+
}
|
|
240
|
+
}
|