Current as of v0.118.1. This is the fullest architecture write-up;
check the stamp against porxpy/__init__.py before trusting a claim.
Portfolio X-ray Python — a self-hosted tool for analysing the true exposure of an investment portfolio across funds, ETFs, sectors, countries, currencies, and asset classes.
PorxPy runs locally on your own machine. Your portfolio data never leaves it. The only external traffic is read-only lookups against public market-data sources (Yahoo Finance, OpenFIGI, optionally justETF).
Six documents, each owning one question, so nothing is explained twice and there is one place to correct when something changes. In reading order for a new user:
| Document | Answers | Read it when |
|---|---|---|
| GETTING_STARTED.md | How do I install PorxPy, load the fund set that ships with it, and design a first portfolio? | First. It is the only place the installation procedure lives, and it runs end to end: install, import the fund bundle attached to the latest release, create a portfolio, give it cash, set targets, run the optimiser, apply the trades. |
| IMPORT_NEW_FUNDS_GUIDE.md | How do I add a fund the shipped set does not have, and get it fully described? | When you want your own funds in the cache. ISIN and ticker imports, holdings uploads, factsheets and their extraction, §11b's full account of how enrichment identifies and fills a holding, the Edit fund dialog, resolving unmatched values, and what every tile means. |
| README.md (this file) | What is PorxPy, what does it do, and how is it built? | For the feature tour and the architecture — module roles, the cache layout, the request flow, external services, privacy. |
| FACET_TREE.md | How do facets actually work? | When a breakdown reads oddly, or before changing anything that touches one. The four trees, why every level travels together, what unknown means against n/a, what is deliberately not a facet, and §17's verified defects. |
| OPTIMIZER.md | How does the solver decide? | When a design surprises you. The exposure matrix, why cash is a reservation rather than a column, greedy selection and swap refinement, why a tolerance is a share of its target rather than a distance, how that same allowance weights the objective, and §13's verified defects. |
| CHANGELOG.md · WISHLIST.md | What changed, and what might change? | The changelog explains the cause behind each release, not just the symptom. The wishlist is deferred ideas — deliberately not a defect list; defects live in the owning document's Known open issues. |
The two guides are the user path — how to work the app. The three
reference documents are the why path — the reasoning behind the
behaviour, kept because that is the part that does not age. Each carries
a version stamp naming the release it describes; check it against
porxpy/__init__.py before trusting a claim.
If you hold three ETFs and a couple of single stocks, your real exposure is hidden inside the funds. A "global equity" ETF and an "S&P 500" ETF look distinct on paper but overlap by 60% in the same US large-caps. PorxPy unpacks every fund into its underlying holdings, weights them by your allocation, and shows you the merged reality:
- Which companies do I really own, and how much?
- Where in the world is my money invested?
- Which sectors am I overexposed to?
- What currencies am I really running?
- How much is equity vs fixed income vs cash, after looking through every fund?
Cash gets a rule of its own, because "cash" means two things. Money in your own accounts is a position — it lives on the Cash sub-tab, and it is not listed among your funds or among the funds' holdings, because no fund holds it. But it is still your money, so in the X-ray it is added to the funds' own cash sleeves and answers as one number: separate by position, merged by exposure. The portfolio overview states all three figures with their shares of the total — funds, cash, and the whole.
Every other percentage in the app is a share of the fund side. You say how much cash to keep as an amount ("50,000 stays liquid"), it is reserved before anything else, and the funds list, the holdings table and every target then describe what is left.
The portfolio's sub-tabs are grouped by what they answer — what you hold (Funds, Holdings, Cash), how it has behaved and where it is exposed (History, X-ray), and what you want and how to get there (Targets, Optimizer). The middle pair carries an Include cash held by me checkbox, because it is the only pair the answer genuinely changes for.
"What sector is this?" and "where is this?" are not single questions, so they are not single numbers. Each is a tree, and the same breakdown is available at every level of it:
- Sector — sub-sector → sector → super-sector. Semiconductors, technology, cyclicals. A semiconductor fund is not a technology fund, and asking whether you are overweight "tech" means something different at each of the three.
- Country — country → region → super-region. Japan, Asia Developed, Developed Markets. "How much of me is in emerging markets?" is a super-region question; "how much in India?" is a country one.
- Asset — sub-class → asset class → super class. Government bond, government, fixed income. "How much fixed income do I hold?" and "how much of that is government paper?" are two different questions, and a holdings file that says only "bond" answers the coarse one without pretending to answer the fine one.
The level selector sits on the breakdown cards — on the fund page and on Portfolio → X-ray alike — and on both holdings tables, the fund page's and Portfolio → Holdings. Every level is computed independently rather than by rolling the finest one up. A fund that only publishes region-level data still contributes to the region distribution instead of being lost in it — and a holdings file that says "Europe ex-UK" answers at region level rather than being flagged as an unrecognised country.
Set your targets, and PorxPy will propose the trades that get you there: which funds to buy, which to sell, and how much. It works from the funds you have already loaded, cannot overdraw your cash, and shows the residual error per facet so you can see exactly where the design still misses and decide whether to relax a target or go find another fund.
Any fund can be opted out with the incl checkbox in the pre-loaded list. A holding you have opted out of is left alone — but its exposure still counts toward your targets, so the optimiser designs around it rather than pretending it isn't there.
Alongside the design, the optimiser prices every better-scoring alternative to each fund it chose: swap the peer in, re-solve the weights, and report what the substitution actually costs — "this fund scores 12, its peer scores 95, taking it costs you 1.2pp of country accuracy". Nothing is applied. An optimiser that spent an error budget automatically would be guessing at how much accuracy you are willing to trade, which varies per portfolio and is exactly the judgement you are best placed to make. Alternatives that break a tolerance are shown and flagged rather than hidden, and each is priced against the same baseline independently — so accepting two does not cost the sum of their two prices, and the combined result is recomputed before anything is applied.
You set target allocations per facet (e.g. "40% North America, 25% Europe, 20% Asia, 15% emerging markets"). PorxPy compares your portfolio against them and shows signed deviation bars — green for overweight, red for underweight — so you can see at a glance where you're off your plan.
A target names a level as well as a bucket, so "25% Europe" at region level and "10% Germany" at country level are two different statements and are fitted at their own grain. Where both are set, a parent is held to at least the sum of its targeted children — that rule is enforced when you save, not discovered later as an unmeetable brief.
One setting is not a target at all. Cash held by me is an amount, not a percentage, and it is reserved before anything is designed: the optimiser leaves exactly that much in your own accounts, selling positions to raise it if you currently hold less and investing the difference if you hold more. Every percentage target is then a share of what remains — reserve 50,000 of 100,000 and "50% equity" means 25,000.
Because targets nest that way, the per-facet total shown while you edit them, and on the Targets tab, is what they commit, not the levels added together: every target is rolled up into the bucket that contains it and counted once, at the coarsest level. Targeting semiconductors 15% inside technology 35% commits 35%, not 50%. So the figure stays on a scale where 100% means the whole category is spoken for, and a total above it is a genuine over-commitment rather than an artefact of counting a sub-sector twice.
Targets come in two groups. Exposure — asset class, sector, country, currency — is measured by looking through your funds to what they actually hold. Fund classification — market cap, equity style and thematic focus — describes each fund as a whole, so a fund nobody has classified shows up as "unknown" rather than being quietly dropped from the denominator. Both groups are targetable; "unknown" is not, because it is a gap in the data rather than something you can aim for.
Thematic focus (v0.104.0) is the newest of those, and it works the way a theme actually works: a fund built around artificial intelligence counts toward an AI target with all of its money, because every holding in it was bought for that theme. There is no look-through that could say otherwise — the theme is a property of the fund's mandate, not of its holdings. Set it per fund in Edit fund (Focus → Thematic, and name the theme); the Targets editor then offers a dropdown of the themes your own funds actually carry, with the number of funds behind each, and nothing else — a target on a theme you hold no fund for could never be met by any selection. Everything that is not a thematic fund answers "no thematic focus" rather than "unknown": a European tracker is not a theme fund whose theme is missing.
Two documents describe a fund properly, and both are revised on a
schedule: the issuer's monthly factsheet and its holdings file. Get
latest Factsheet and Holdings, on the fund page beside Reload Fund
Data, goes and gets them. The factsheet is stored against the fund and —
if the AI helper is switched on and has a key — read, with the result
applied to every field you have pinned to factsheet. The holdings file
is imported with the column mapping you entered last time, enriched
through Yahoo, and replaces the uploaded holdings.
The two buttons are a pair: Reload Fund Data re-asks Yahoo and deliberately leaves uploaded holdings alone, so this is the half that was missing.
PorxPy recognises six fund houses — iShares, Vanguard, Amundi, Xtrackers, VanEck and SPDR. It can find documents by itself at iShares (both), Xtrackers (holdings) and Amundi (factsheets). The gaps are where the issuer publishes nothing addressable: Xtrackers' factsheets sit behind an opaque download id, and Amundi publishes no holdings file at all.
A fund house runs a separate site per market, each listing only the share classes sold there, so each house carries an ordered list of sites and they are tried in turn. That is not a detail: the same iShares ETF is IWDA in Amsterdam and SWDA in London, and its factsheet exists under only one of those names. Set the order in Settings → Fund houses — put the market your funds are listed in first — and any site you paste a document URL from is added to the list on its own.
One issuer's holdings exports share a layout across its whole range, so a mapping can be promoted to a house standard: PorxPy offers this after you map a holdings file, and offers the standard back when it refreshes a fund that has no mapping of its own. Both are questions, never assumptions, and the standard is checked against every file it is applied to. That is less of a limit than it sounds: for any fund of any house, if you once supplied a document by URL rather than by dragging a file in, that URL is remembered and re-fetched, so the button works from then on. When PorxPy cannot locate something it says so, and says that one manual upload is what teaches it the address.
When no saved mapping fits the file it downloads, PorxPy opens the holdings mapping dialog with that file already in it — and opens the file itself in your spreadsheet application beside it — rather than telling you to go and fetch it yourself.
Two things are refused rather than guessed. If no API key is set the factsheet is still fetched and stored, and only the reading is skipped — reported as a skip, with the fix, not as a failure. And a downloaded holdings file is checked against the columns your saved mapping was made on before it is allowed to replace anything: a file whose layout has changed is refused, because a mapping applied to the wrong file produces rows that parse cleanly and mean nothing.
Two funds can supply the same exposure at very different cost. PorxPy scores every fund in your pre-loaded set on three components — TER, fund size, and trailing returns — as percentiles, so a cost in fractions of a percent and a size in billions can be combined without inventing an exchange rate between them. Fund size is a floor test rather than a percentile: past "big enough not to be at risk of closure", more of it is not better.
Each fund carries two scores:
- Overall — against the whole universe. Answers "is this a good fund", and is what the fund list and detail page show.
- Peer — against funds with the same asset class and focus. Answers "is this the best fund for this job", which is the only question worth asking when the optimiser holds a European bond fund because the targets demand one. Ranked globally, bond funds would sit at the bottom of any returns-weighted score permanently — not because they are bad but because they are bonds — and offering to replace one with a high-scoring US equity tracker would be answering a question nobody asked. The optimiser's alternatives table reads the peer score and nothing else.
Three weight presets ship in Settings — cost driven (the default), cost and returns, and returns driven — and each score travels with its coverage, so a high score computed from one component out of three is visibly thin. A peer group of one fund yields no peer score at all — there is no ordering inside a group of one, so any number would be invented — but a group of two is ranked: one of the pair does outrank the other, and refusing to say so left those funds unrated and invisible to the optimiser.
Percentiles are the plotting position rank / (n + 1), counting from the
worst. A pair scores 33 and 67, a trio 25/50/75, the whole 51-fund
universe 2 through 98. That is why nothing ever scores exactly 0 or 100
on a ranked component: nothing is best-in-class against a field that
might yet grow, and the alternative — stretching whatever field is to
hand across the full range — declared one of two funds perfect and the
other worthless on the strength of a basis point between them. The size
component is a floor test rather than a ranking, so it still scores
exactly 0 or 100.
Every place a score appears also names the model it was computed under — the fund list's column header, the fund page's Score row, the peer lists, and the optimiser's trade and alternatives tables. A rank means nothing without the weights it was taken over: the same fund is 96 under Cost driven and 12 under Returns driven, and a bare 12 reads as a bad fund rather than as one the chosen model does not reward.
The peer group is visible from both places a score is — the fund list's Peers column and the fund page's Score row — as ISIN, name and score in three columns, ranked best first. Either list carries the picker for the weight model, and the model is one setting: changing it in one place re-ranks the other, since two lists of the same funds under different weightings would be two answers to one question. The optimiser's own quality picker is separate on purpose — that model is part of a run, not a way of looking at a list — which is why its tables are captioned with the model that run actually used, not with whatever the picker reads now.
The fund price chart can also overlay every peer's price series — indexed to 100 at the left edge of the window, since two funds priced at 12 and 480 tell you nothing side by side.
Every fund carries five dots — fund data · asset · sector · country · currency — in the pre-loaded list, the portfolio funds table and the fund page header. Colour runs red → orange → yellow → green, so one look down the column finds the weak facet and one look across a row finds the weak fund. The column sorts by the worst dot first: a fund with four greens and a red is a fund with a red.
The four facet dots are that facet's coverage — how much of the fund's
exposure its chosen source can actually place, with unknown counting
against it and n/a counting as answered, because a cash sleeve has no
sector and never will.
A solid dot was measured; a hollow one was reported. Solid means the number was counted from actual positions (a holdings look-through or an upload). Hollow means the issuer published it, or you asserted that a partial source covers the whole fund. Both can read 100%, and they are not the same claim — so the difference stays visible rather than being averaged away.
The fund-data dot is not a completeness score. It answers whether the optimiser can rate this fund, which turns out to be a different question: a fund can have every field filled in and still be unrankable. Three states, three different things to do about them:
| meaning | what fixes it | |
|---|---|---|
| red | a rating input is missing — TER, size, or focus | supply it: a factsheet, or your own value |
| orange | inputs are all present, but too few peers to rank against | load more similar funds, or broaden the focus |
| green | the optimiser can rank it | nothing |
Orange is the one worth knowing about, because its fix is the
counter-intuitive one. Peer groups are primary asset class × focus × focus detail, and a group needs three members before anyone in it can be
ranked. An over-specific focus detail splits the universe into groups of
one — so narrowing a fund's description can be what makes it
unrankable, and broadening it is the repair.
How to read a dot is on the dot. Hovering any dot gives that dot's own reading — "Sector — 82% at sector · counted from holdings" — followed by the whole scale: what each colour means, and what solid and hollow mean. The column headers carry the same text, and the fund page prints the two-line version under its labelled block, since that is the screen where you act on a dot.
The dots follow what you change. Switching a card's breakdown source, declaring a facet complete, editing a fund's focus, uploading or matching holdings, adding an alias in the Resolve dialog, or adding and deleting funds all move a dot — and all of them repaint it immediately, along with the score column and the peer list, wherever those are on screen. Peer groups in particular are a property of the universe rather than of one fund, so loading a fund can take its peers from orange to green without anything about them having changed.
- Add funds by ticker or ISIN. PorxPy fetches profile, price history, and holdings from Yahoo Finance.
- Group funds into one or more portfolios, with shares (or units) held per fund, plus cash positions.
- See the same company once across every fund that holds it, on Portfolio → Holdings. The Funds column says how many of your funds hold each merged position and opens to name them, so a row you did not expect to be that large can be traced back to where it came from without filtering the funds table one fund at a time.
- Read the bond metadata an issuer's holdings file carries — duration, maturity, coupon, effective date and credit rating — on import, in both holdings tables, and merged across funds at portfolio level. Ratings sort by credit standing rather than alphabetically, and both agencies' scales are understood (BBB- and Baa3 are the same rung).
- Upload full holdings CSVs or Excel files when the fund issuer publishes them (top-10 from Yahoo is often not enough for a real X-ray). Yahoo can fill the columns the file leaves blank, during the upload or afterwards from the holdings tile — the same pass either way, identifying each holding by its ISIN first, then ticker, then CUSIP, and only then its name, because an ISIN names one security where a name is a guess. Nothing the file supplied is overwritten, with two deliberate exceptions: an identifier column that is malformed is replaced with the real one (a valid one never is), and where an identifier resolved the holding, Yahoo's name replaces the issuer's, so "APPLE INC COMMON STOCK USD0.00001" becomes "Apple Inc." A name-search match never rewrites the name it searched on. Tick rows in the holdings table to enrich only those. IMPORT_NEW_FUNDS_GUIDE.md §11b is the full account: what is filled and what is never touched, the resolution chain in order, what counts as a refinement of a value the file already gave, and what is cached between runs.
- Hold three position lists per fund at once — Yahoo's top-10, the table read off the factsheet, and your uploaded file — and choose which one the fund shows. The choice is saved with the fund, so every portfolio holding it and the optimiser use the list you picked; an upload no longer destroys what Yahoo said, and removing it takes you back rather than leaving you with an empty table.
- Upload the issuer's factsheet for funds Yahoo covers badly, and pin any breakdown card to it. Optionally, let the AI helper read the document and stage the fields, breakdowns and positions it finds — every value carrying the page and verbatim quote it came from, and nothing applied until you say so.
- Derive the currency card from the country card, for the many funds whose issuer publishes a geographic split and no currency split at all. "From country" converts the country card country by country to each country's primary currency, and then follows it: an unknown slice there stays unknown here, and marking the country card coverage-complete marks the derived currency card complete too. Not the reverse — a currency does not name a country, so country-from-currency would invent detail rather than convert it, and the currency card's own completeness assertion says nothing about country.
- Tell a breakdown card that its source covers the whole fund, when
the breakdown rests on a partial holdings list and nothing better
exists. The unknown slice is dropped and the rest scaled up, and the
assertion is saved with the fund — so the portfolio X-ray, the target
deviations and the optimiser all read the fund as fully described,
which an "unknown" slice never lets them do. The badge still says
ASSUMED, so nobody mistakes the assertion for a measurement. - See where every field's value actually came from. A source is recorded where a value is produced, never assumed: a fund class worked out from words in the fund's name says "inferred from name" rather than claiming to be Yahoo data, and a field nobody has a source for says nothing at all rather than guessing.
- Override anything that's wrong at the source — asset class, replication method, breakdown source, TER, total net assets — and the override sticks across every portfolio that holds the fund and survives every refetch. Overrides are a view over the fetched data, never a mutation of it, so clearing one restores the original value without a round-trip.
Holdings files from different issuers use different ticker conventions
— Bloomberg-spaced (AIR FP), Bloomberg dot-separated (AIR.FP),
Refinitiv suffixes, concatenated country codes (PLTRUS). PorxPy
resolves them through a fallback chain so you don't have to clean the
file before uploading:
- Variant probing — generate plausible Yahoo equivalents and try them in order.
- ISIN country prefix — map the first two characters of the ISIN
(e.g.
FR) to the Yahoo exchange suffix (.PA) and try the bare ticker with that suffix. - Identifier search — pass the CUSIP or ISIN directly to Yahoo's search endpoint.
- Name search — search Yahoo by security name and match results against the first characters of the raw ticker.
Once resolved, the canonical ticker, ISIN, and CUSIP are all written back to the holding row so you have clean data going forward.
A facet value that doesn't resolve against its resource file is not a
bucket. It counts as unknown, and the raw text is kept and shown
beneath the slice — of which unrecognised: "Diversified Holdings" 6%.
The alternative, which PorxPy used to do, was to let the raw text be the
bucket key, so an unrecognised sector became a slice called "Diversified
Holdings" — conflating this fund holds 8% of that thing with the
source said something we could not place, when only the first belongs in
a distribution.
Every facet also distinguishes two residuals: unknown is a gap somebody
could close, n/a means the question does not apply. One bucket could
not say which, and the reader wants to know whether better data would fix
it.
In the holdings tables the same distinction is drawn a row at a time.
— means this row says nothing about this facet; unknown means it says
something, just not at the grain you are currently looking at. A single
column could not tell you which, so switching level looked like data
appearing and disappearing.
The Resolve unmatched values dialog lists what didn't resolve, sorted by weight, with a row per distinct value across every fund and every source. Tell it what a value means once and the alias is written to the resource file; every occurrence everywhere resolves on the next read, including a factsheet extracted last month — resolution happens at derivation time, so nothing is rewritten and no migration is needed.
Building a usable pre-loaded set — a hundred-odd funds with holdings, factsheets, corrected structure fields and per-facet source pins — is days of work. Two bundle types, deliberately kept separate:
- Funds — the curated asset. What a fund is and what was established about it, including factsheets and resource files. Portable between installs and between users.
- Portfolios — what you hold: portfolios, targets, cash and settings. Personal, and restored on top of whatever fund set is present.
Mixing them would mean you cannot take someone else's fund research without also taking their holdings.
Both are plain zips with a readable manifest.json, so a failed import
can be diagnosed by opening the file. Import is two-phase: the bundle is
inspected against your install first and nothing is written until you
have seen the conflict table, where overwriting states its cost inline
("replaces 3 of your edits") at the moment of choosing rather than
afterwards.
PorxPy is a small Flask application with a single-file vanilla-JS
frontend. There is no database; everything lives in JSON files at the
project root or under cache/. There is no framework on the client
side either — just one HTML file with embedded JavaScript.
main.py Entry point — starts Flask on 0.0.0.0:5000
porxpy/
app.py Flask app factory and all HTTP/API routes
config.py Paths, constants, TTLs, facet/level tables,
the overridable-field registry
extractors.py Yahoo Finance fetching and per-holding enrichment
resolver.py Ticker variant generation and resolution chain
breakdowns.py Holdings roll-up → per-facet levelled breakdown
targets.py Target-vs-actual deviation computation
scoring.py Fund scoring and peer groups (cost / size / returns)
ai.py Factsheet extraction via the Anthropic API (opt-in)
optimizer.py Greedy portfolio design against exposure targets
trades.py Atomic trade execution (cash ↔ fund positions)
upload.py Holdings file parsing, column mapping, enrichment
issuers.py Fund-house adapters: locate and fetch the issuer's
own latest factsheet and holdings file
bundles.py Export/import of fund sets and portfolio backups
utils.py Cache I/O, portfolio data, coercion helpers
resources.py Reference-data loading and facet-value resolution
yf_session.py Yahoo transport: TLS impersonation pinning and the
trust store (see "TLS interception" below)
fund_explorer.html Single-file frontend (HTML + JS, no framework)
resources/ Reference CSVs (shipped with the project)
Geography_definitions.csv country → region → super-region
Sector_definitions.csv sub-sector → sector → super-sector
Asset_definitions.csv sub-class → asset class → super class
Currency_definitions.csv ISO-4217 currencies
Primary_asset_class_definitions.csv what kind of fund this IS
Asset_definitions.csv and Primary_asset_class_definitions.csv are
close in name and answer different questions. The first is the
vocabulary the asset BREAKDOWN rolls up to — a distribution over a
fund's holdings, at three grains. The second is a single CLASSIFICATION
of the fund itself, captured from Yahoo, a factsheet, justETF or the
user, and never derived from its holdings. A 60/40 fund is mixed in
the second while its breakdown is roughly 60% equity / 40% fixed income
in the first. Peer-group selection reads the classification.
Asset_definitions.csv arrived in 0.70.0 and replaces
Holdings_class_definitions.csv (the per-holding class of a row) and
Fund_class_definitions.csv (the vocabulary the rollup aggregated to).
Those were two files describing one taxonomy at two grains, and they
disagreed on spelling — bond in one, fixed_income in the other, for
the same concept. Both must be deleted from resources/ when
upgrading.
The frontend has four tabs — Explore Funds/ETFs, Portfolio (with Funds, Cash, History, X-ray, Targets, Holdings and Optimizer views), Tools (source inspector, resource reload) and Settings.
All five resource CSVs share one hierarchical schema:
type,name,description,parent_name,matches,is_default,attrs
The delimiter is sniffed per file rather than fixed, so a file saved by
a spreadsheet in a locale that uses ; is read as-is instead of having
to be converted first.
parent_name is singular, which is what makes each tree a chain by
construction rather than by convention. matches holds every spelling
that should resolve to the row — alpha-2 and alpha-3 codes, numeric ISO
codes, index names, and non-English labels, since a Dutch factsheet says
Aandelen and a holdings file may say 756 for Switzerland.
Which levels exist per facet:
| Facet | Levels (finest first) | Default level |
|---|---|---|
sector |
sub_sector → sector → super_sector |
sector |
country |
country → region → super_region |
country |
currency |
currency |
currency |
asset_class |
sub_class → asset_class → super_class |
super_class |
The country facet is fed by Geography_definitions.csv — the file
covers the whole tree, so naming it after the finest level would have
been wrong, but the facet keeps the name every consumer already stores.
The same file also carries focus_group rows (europe, asia, pacific,
world), which sit outside the chain: they are the pan-regional groupings
a fund name can imply but a holding cannot belong to uniquely, which
is exactly why they cannot be levels.
asset_class defaults to super_class rather than to its own middle
level. Until 0.70.0 the asset vocabulary WAS equity / fixed_income /
cash / other, which in the tree is the super level (today's four are
equity, fixed income, liquid and other); defaulting to the middle one
would have silently moved every existing target, deviation and optimiser
fit to a much finer grain than the one it was set at.
The default level is fixed per facet and never computed from the data: "deepest available" would make the meaning of a word depend on what happened to arrive, so uploading a more detailed factsheet for one fund would silently change the grain its targets are measured at. Non-tree facets declare a single level of the same shape, so nothing downstream has to branch on whether a facet has levels.
The table above lists the four facets that have breakdown cards and
are measured from holdings. Three more — market_cap, style_box and
focus_theme — are targetable without being breakdowns: they are read
as a single value off the fund's own classification, one bucket per
fund at weight 1.0, so there is no source to choose and no level to
select. focus_theme is the only targetable facet with no vocabulary
behind it at all: a theme is free text, so the values it offers are
whatever your own funds declare.
Editing a resource file needs no version bump — the files are hashed, and Tools → Reload resource files picks up changes without a restart.
portfolios.json Your portfolios (name, funds, shares, cash, targets)
settings.json App-level settings (enrichment, scoring presets,
factsheet staleness, AI on/off)
overrides.json Per-fund overrides, keyed by ISIN, then by field
({value, source, ts, note} per assertion)
isin_map.json Cached ISIN → ticker resolutions (from OpenFIGI)
cache/
factsheets/<isin>.* Uploaded issuer factsheets + metadata sidecar
listings/<ticker>.json Per-listing data (price history, profile,
remembered upload column mapping)
funds/<isin>.json Per-fund data (holdings, breakdowns, sectors,
remembered upload sources)
_symbol_info.json Shared per-symbol info cache (HQ country, etc.)
_symbol_aliases.json Resolved ticker alias cache
FX_*.json / FXH_*.json FX spot and historical rate caches
uploads/ Server-side scratch for in-progress holdings uploads
The cache distinguishes between listing-level data (ticker-keyed — price, profile) and fund-level data (ISIN-keyed — holdings, sectors, breakdowns). Two different listings of the same fund (e.g. the GBp and USD share classes of one ETF) share one fund-level cache entry, so a holdings upload made against either listing is immediately visible from both.
Two fund-level slots hold one entry per source rather than a single
value: uploaded_breakdowns keeps a CSV upload and a factsheet reading
side by side per facet, and (since 0.77.0) holdings keeps Yahoo's
top-10, the factsheet's position table and your uploaded file side by
side. In both cases the sources are independent assertions about the
same fund, writing one leaves the others untouched, and which one is in
effect is a fund-level override applied on read — never a property of
the stored data.
The user-data files at the project root (portfolios, settings,
overrides) live outside cache/ on purpose: cache is "stuff we can
lose without losing user state", and is purgeable. User intent is not.
Factsheets are the exception that proves the rule — they sit under
cache/ because they are fetched artefacts, but they are never expired
automatically, because nothing else can replace a document the user went
and found.
A typical fund page render looks like:
- The browser opens
/, which servesfund_explorer.htmlas static content. - JavaScript on the page calls
/api/fund?ticker=.... - Flask asks the cache layer for the fund's data; on miss or expiry,
extractors.load_fund_datafetches from Yahoo, applies overrides, and writes back to the cache. - The JSON response includes profile, price history, the four breakdown cards (each from its configured source, each carrying every level it has), and the merged holdings list.
A portfolio X-ray goes through /api/portfolios/<pid>/view and is
much the same, but adds a final aggregation pass in breakdowns.py
that weighs every fund's facet breakdown by its allocation in the
portfolio — once per level, since a fund contributes to each level
independently.
| Service | Purpose | When called |
|---|---|---|
| Yahoo Finance | Fund profile, price history, holdings, FX, search | Always (the main data source) |
| OpenFIGI | ISIN → ticker resolution | When adding a fund by ISIN |
| justETF | ETF structure (replication, style) — best effort | Optional, ETFs only, user-confirmed |
| Anthropic API | Reading an uploaded issuer factsheet | Off by default; only when you ask |
| Fund-house sites | The issuer's own current factsheet and holdings file | Only when you press "Get latest Factsheet and Holdings" |
All responses are cached locally with TTLs that reflect how often the underlying data actually changes (price: 1 day, sectors and issuer asset allocation: 7 days, profile: 30 days, fund asset class: 90 days, ISIN→ticker: 30 days, FX spot: 6 hours, FX history: 24 hours). Holdings are the exception: they are never expired on a clock, because a fund's holdings only change when you ask for them — "Reload fund data" on the fund page, or "Refresh all" on the portfolio.
Software that inspects HTTPS — most antivirus "web shields", most corporate proxies — does not observe your traffic, it replaces it. It terminates the connection itself and re-signs the response with a root certificate of its own, which it installs in the operating system's certificate store. Every browser on the machine keeps working and shows no warning, because browsers read that store.
Python does not. It ships a fixed list of public certificate
authorities (certifi) and knows nothing about the machine's own. So
the moment such a scanner is switched on, every Yahoo call starts
failing with:
curl: (60) SSL certificate problem: unable to get local issuer certificate
and PorxPy reports it as "Yahoo did not return live data for …", because from the fetch's point of view that is all it knows.
PorxPy handles this itself: at startup it builds a certificate bundle of
certifi's roots plus the roots the operating system already trusts,
and points its Yahoo transport at it (porxpy/yf_session.py; the startup
banner logs how many were added). Verification stays on, and nothing is
trusted that the machine did not already trust. Set PORXPY_CA_BUNDLE to
a PEM file to supply your own instead.
Turning the scanning off, if you would rather. In Avast Free Antivirus the two controls are in different places:
- Exclude individual sites — ☰ Menu ▸ Settings ▸ General ▸
Exceptions ▸ Add exception, confirm with Next ▸ I understand the
risks, choose the Website / Domain tab, enter the domain (e.g.
api.openfigi.com) and click Add. - Stop inspecting HTTPS altogether — ☰ Menu ▸ Settings ▸ Scam Guardian ▸ Web Guard, and untick Enable HTTPS scanning. Avast then stops re-signing encrypted traffic; the rest of Web Guard's protection stays on. (Web Guard was called Web Shield in older versions.)
To check whether a host is still being intercepted, look at who issued its certificate:
echo | openssl s_client -connect api.openfigi.com:443 -servername api.openfigi.com 2>/dev/null | grep issuerA real certificate authority (DigiCert, Amazon, Let's Encrypt) means the traffic reaches the site untouched. The scanner's own name there means it is still in the middle.
Known limit. This restores Yahoo, which is where the funds come from,
but not the requests-based lookups — OpenFIGI ISIN resolution and
justETF enrichment. Python 3.13 enabled the strict X.509 check
VERIFY_X509_STRICT by default, and some scanner roots are malformed in
a way it rejects outright (Avast's, for one, does not mark its basic
constraints critical) no matter which bundle they are offered in. Turning
that check off would relax certificate validation for every connection
the app makes, so PorxPy does not. If you need those two services while a
scanner is active, exclude api.openfigi.com and www.justetf.com from
its HTTPS scanning, or turn that scanning off.
Individual fields age by group rather than by cache category, since how often something changes is a property of the data and not of where it happens to be stored: identification effectively never (an ISIN does not change), structure yearly, operational figures like TER and fund size quarterly, trading data daily. That is what the field-level freshness indicators and the per-field re-source controls read.
- Python 3.12 or newer
- A modern browser (Chrome, Firefox, Safari, Edge — anything from the last few years)
- Internet access (for Yahoo Finance / OpenFIGI lookups; once cached, PorxPy works offline)
Dependencies (see requirements.txt):
Flask— HTTP serverFlask-Cors— CORS supportyfinance— Yahoo Finance clientrequests— HTTP for OpenFIGI / justETF / Anthropicpandas— CSV / Excel parsing in the holdings upload flowopenpyxl— Excel file reading
Installing PorxPy, loading the pre-loaded fund set that ships with the repository, and designing a first portfolio end to end are all covered in GETTING_STARTED.md — start there.
The short version: clone the repo, pip install -r requirements.txt,
python main.py, open http://127.0.0.1:5000, and import the fund
bundle attached to the latest release —
https://github.com/jdderijke/PorxPy/releases/latest/download/porxpy_funds.zip —
from Settings → backup & restore so there is a fund universe to work
with from the first minute. The bundle is a release download rather
than a file in the repository: git cannot merge a 30 MB zip, so a
tracked copy collided with git pull for anyone who had re-exported
over theirs.
For everything about getting more funds in — ISIN and ticker imports, holdings uploads, factsheets, structure fields and scores — see IMPORT_NEW_FUNDS_GUIDE.md, and for what the other documents cover, the map at the top of this file.
PorxPy is single-user and self-hosted. Your portfolio data never leaves your machine. The only outbound traffic is:
- Yahoo Finance — symbol lookups, price data, holdings, FX rates, search.
- OpenFIGI — ISIN-to-ticker resolution (public, unauthenticated API).
- justETF — only when you explicitly trigger the structure lookup, and only for ETFs by ISIN.
- Anthropic API — only if you switch on the AI helper in Settings, and
only the factsheet you uploaded. No holdings, no portfolios, no
positions. Off by default. The API key is entered in Settings and
stored in
settings.json, which is gitignored and never committed; anANTHROPIC_API_KEYenvironment variable is used instead when Settings holds no key. The key is never sent back to the browser — the page only ever sees a masked hint likesk-ant…WXYZthan stored.
Bundles are files on your disk. Nothing is uploaded anywhere, and a
fund bundle deliberately leaves out the overrides that describe how
you use a fund rather than what the fund is. It also strips the
filesystem paths behind remembered uploads: an issuer URL is kept,
because the recipient can fetch from it too, but C:\Users\you\… is
inert on their machine and would carry your username and folder layout
along with the research.
There are no telemetry, analytics, or update checks.
Verified defects in the parts of PorxPy this document owns — the app as a
whole, its endpoints and its transport. Deliberate design boundaries are
described where the feature is, not here, and each of the other design
documents keeps the issues of its own subject: OPTIMIZER.md §13 for the
solver, FACET_TREE.md §17 for the facet trees. Ideas that are merely
absent rather than wrong belong in WISHLIST.md.
Nothing is currently open here. The two defects listed at v0.80.0 — a portfolio view shipping every fund's price history and holdings, and Flask's debug mode doubling every JSON response — were both fixed in v0.81.0; see the CHANGELOG for what each was and why the fix sits where it does.
Current release: 0.118.1 (2026-09-14)
See CHANGELOG.md for the full version history.