Skip to content
jrio.dev

I am Jose, from Salamanca, Spain.

This is where I leave my small personal projects.

~/jose.json
name
José Martín Río
linkedin
in/josemartinrio

Projects

Divr In production April 2026divr.es ↗ Where I keep my own portfolio: shares, transactions, dividends and PUT options, with the Spanish income tax already worked out. Web and mobile app. bme-fundamentals came out of this one, the day I stopped trusting the data I was using. GoPostgresReactExpo Look inside Explore architectureInteractive diagram (Spanish)
bme-fundamentals In development August 2026api-production-2a50.up.railway.app ↗ Pulls the financial statements of Spanish listed companies straight from what they file with the regulator. I built it because the source I used before returned zeros where there was no data, and I was swallowing errors without noticing. GoPostgresXBRL Look inside Explore architectureInteractive diagram (Spanish)
Chess Coach In production August 2026chess-coach-production-5b27.up.railway.app ↗ You paste the PGN of a game and it tells you what you played wrong and which mistakes you keep repeating. Stockfish runs the analysis on the server, so it comes from the engine and not from my opinion. TypeScriptNodeStockfish Look inside Explore architectureInteractive diagram (Spanish)

About me

I have been programming for nine years, almost always on the back end: APIs, databases and getting the data clean.

Every project starts the same way: something I needed and could not find, or that existed in a form I did not trust.

Nothing special, but they work and I use them every day.

Contact

If you want to get in touch, you can do it at:

In production April 2026

Divr

A portfolio for long-term dividend investors across the Spanish, London and US markets. Positions, transactions, dividends with their withholding tax, scrip and PUT options.

Why it exists

I used to keep the portfolio in a spreadsheet that broke every year in the same place: dividends with tax withheld at source, and option premiums. What I needed was not a stock app, it was for the number on the tax return to be right without rebuilding it by hand.

That is where the rule everything else hangs from comes from: every amount is converted to euros at the rate of its own date, because the return needs the rate of the day the money actually moved.

What it is built with

Web

React 19ViteTanStack QueryTailwind

Mobile

React NativeExpo

Server

Go 1.25GinpgxPostgreSQL 16

Outside

bme-fundamentalsYahooECBClaude

How it works inside

Three clients, one API, one database. Every layer looks downwards and none of them reaches back up.

Client Web Where I enter transactions and look at the tax year React · Vercel
Client Mobile app The same portfolio in your pocket. It shares the types, formatters and FX helpers with the web Expo
Entry HTTP layer Routes, status codes, auth and allowed origins. No money is calculated here Go · Gin
Service The only layer that calculates All the money and all the currency conversion live here. It is what stops the web and the phone from reaching two different answers for the same tax year
Domain Pure rules Average cost, the result of an option. No database and no network, so they test themselves
Repository SQL only And always with the owner in front: there is no fetch by id without checking whose it is
Adapters Everything outside, behind an interface Any external service can be swapped, or faked in a test, without touching a business rule
Store PostgreSQL Numbered migrations that apply themselves on boot and are never edited afterwards
Own source bme-fundamentals The accounts of Spanish issuers and their dividend calendar, cached. The other project, the one that was born right here
Third parties Quotes and FX rates Valuation data and the European Central Bank's daily rate, captured at the transaction date

Decisions that changed something

  • Weighted average cost, not FIFO. It is what Spanish income tax applies to listed shares, so FIFO is not a missing option: it is one that must not exist. Buying re-weights the average, selling leaves it alone, and recomputing it on a sale is the classic way to get this wrong.
  • The rate is stored the way the central bank publishes it, and converting to euros is a division. Multiplying gives a believable, wrong number. The rate is captured at the transaction date and frozen on the row: old rows are never re-rated.
  • Withholding depends on the market, not on the currency. A Spanish bank pays a US dividend into a euro account, so the currency the money arrives in says nothing about what was already withheld.
  • The dividend is stored gross. The net figure depends on who collects it. What the bank sends is the net, so the gross is reconstructed and left editable before anything is saved.
  • In options, one contract is a hundred shares, and that hundred appears exactly once in the whole server. If you find a second one, one of them is a bug.
  • Anything belonging to someone is read and written with its owner in front. In a portfolio, an isolation bug is not a privacy bug: it is somebody else's tax return.
  • Every integration is optional and says so. Without its keys the section reports itself unconfigured instead of showing an empty page, which is the same deception that created bme-fundamentals wearing different clothes.

See it running

The repository is private. The web is open at divr.es, and the Android app comes from the same place.

bme-fundamentals Open the API
In development August 2026

bme-fundamentals

The financial statements of Spanish listed issuers, taken from the regulator's official XBRL filings and not from an aggregator.

The rule that governs everything

A figure cannot exist without saying where it came from. It either points at the fact it was read from, or declares the formula that produced it, or says why it is empty. There is no fourth case, and a hole is never a zero.

Why it exists

The source my portfolio used served gross profit and operating income as zero when there was in fact no data. The problem was never the missing data: it was that the bad data looked good. A ratio computed on an invented zero looks exactly like a good one.

What going to the source buys is ten full fiscal years instead of the aggregator's four, with earnings per share, operating cash flow, capital expenditure and dividends paid natively tagged by the issuer itself.

What it is built with

Ingestion

GoXBRL 2.1Own PDF reader

Store

PostgreSQLMigrations

Cross-check

ESEFxBRL-JSONLEI

Service

REST APIRailwayDistroless Docker

How it works inside

Two layers, and the bottom one rules: what the filing says is never touched, and everything else is recomputed from it.

Source The regulator There is no URL per file: you have to drive its form with five requests per issuer before it hands over an archive with that issuer's whole history
Download The filing, exactly as it arrived Stored compressed with the digest of its contents. That digest is the idempotency: anything already seen is never written again. It goes slowly on purpose, with no concurrency Go
Bottom layer Facts What the XBRL says, untranslated and immutable. Nothing is ever corrected here
Top layer Derived annual view One row per fiscal year, computed from the facts with the concept maps. There are three taxonomies (general, banking and insurance) that reuse the same codes with different meanings, so the map will be wrong several times and fixing it has to mean recomputing in seconds
Control Cross-check against ESEF The European report from the same issuer, joined by its legal entity identifier. It is the only independent control over the concept map, and not one served value comes from it
Separate source Dividends with dates Annual accounts say how much was paid, not when. The when are corporate actions the issuer announces as PDFs, and that register is swept every day
Output REST API Annual accounts, dividend calendar and projections, every figure with its provenance or with the reason it is empty. This is what Divr consumes

Decisions that changed something

  • Two separate layers. Facts are immutable and heavy; the view is recomputed. Fixing a mapping means re-deriving in seconds instead of downloading eighty megabytes per issuer again, and it leaves the fragile part as a pure function you can test without a network.
  • The database rejects data without provenance. It is not a convention kept out of good will: there are constraints that make it impossible to write a zero without an origin, a value without a filing or a hole without a reason.
  • The cross-check exists because a bad map does not raise an error, it produces a plausible figure. Net income for banks pointed for a while at the one including minority interests instead of the one attributable to the parent, and it survived the research, the code and a green test. In the European report they are two visibly different concepts, and a cross-check catches it on day one.
  • A discrepancy is recorded, not resolved. The field is flagged and the official figure keeps being served. The flag says something about our map, not about the issuer.
  • An estimate is not an event, and the schema is what enforces it. An event cannot exist without its announcement, and the projections table has no column to store one. Confusing a projected date with an announced one is not a possible slip: it is something you cannot write.
  • An empty calendar and a crashed collector are the same response. That is why the answer carries how far the register has been swept and how many filings were read: with twelve read and no dividend, the silence belongs to the issuer; with zero, the silence is ours.
  • Units are never inferred, and the order of the dates is guarded by the database. A misread scale multiplies by a thousand without breaking a single test, and two dates of the settlement cycle swapped while reading a PDF is the easiest mistake to make and the hardest to see.

Where it stands

The pipeline is complete end to end: download, reading, derivation, persistence, cross-check and API. What is left is not code. It is the first real load, and someone opening the published report of one issuer per taxonomy and recognising the figures. A badly written concept map does not produce an error, it produces a believable number, and no test closes that.

See it running

The repository is private. The service is up and its landing page carries a tester: you paste a key, pick a route and the request goes out from your own browser. Keys are issued by hand, because redistributing these filings to third parties is a question that is not settled yet, and automatic sign-up would be answering it by default.

Chess Coach Open the app
In production August 2026

Chess Coach

You paste the PGN of a game and it tells you what you played wrong, what was better, which phase costs you most and which mistakes you repeat from one game to the next.

Why it exists

Chess sites tell you a move was bad, but not that you have been doing the same thing for twenty games. That is what I wanted to see, so the whole history is kept and the mistakes are grouped by kind.

Whatever is claimed has to be backed, in both directions. A weakness is not declared from a sample of three games: below the minimum the finding is not even computed. And what you do well is only credited with measurable merit, like finding the engine's move right after your opponent slipped.

What it is built with

Server

TypeScriptNode 20SQLite

Engine

StockfishUCIECO book

Clients

ReactViteCapacitor

Delivery

RailwayDockerAPK in CI

How it works inside

From the pasted game to the report. Every box is a real step along the way.

Input A pasted PGN Or a link shared from the phone, which the server resolves
Input Your Chess.com games Up to fifty, searching the last six months of archives
API Node server A session with a random token, passwords under scrypt, and the PGN turned into the positions of the game TypeScript
Engine Several Stockfish in parallel Separate processes speaking UCI, at depth 14, 17 or 20 depending on the level. Every position of the game is evaluated exactly once UCI
Grading What the move cost From the loss in centipawns to the drop in win probability, and from there to the label
Opening ECO book 3,810 entries from lichess, to say on which move you left theory
Reasons Patterns and habits Hanging piece, abandoned defence, missed mate, king left uncastled, early queen
Store SQLite on a volume Games, full reports and the aggregates of labels and phases. The profile is computed by re-reading the reports, which is where the move-by-move detail lives
Output Guided review and profile One screen per idea, and the profile over your last two hundred games
Optional The Why? button A model turns what the engine already said into a reason, with the facts of the position in front of it and forbidden from contradicting it Claude
Next up RAG over chess literature Today the why reasons over the engine's data and cites no opening theory or plans from a book. That is where it fits Pending

The dashed box is not built. It is there because it is what comes next, not because it already works.

Decisions that changed something

  • Every position is evaluated exactly once. A move's loss is the difference between the evaluation before and after, both taken from the side that moved. It gives the same answer as testing every alternative separately and costs a fraction.
  • If you play the engine's move, the loss is zero by definition. The two positions are searched independently and the horizon effect can sink the evaluation even when the move is the best one. Without that rule, a sound sacrifice is punished as a mistake.
  • Moves are graded by the drop in win probability. Losing two hundred centipawns when you are winning comfortably changes nothing; losing them in a level position decides the game. In centipawns those two moves are identical.
  • The model translates, it does not analyse. It only receives verifiable facts about the position and is forbidden from inventing lines or contradicting the engine. Without a key the app works the same and those buttons do not even appear.
  • Your account is your Chess.com username. A separate account name would be one more thing to maintain, and it can drift from the one that actually matters: whose games are pulled and from which side they are read.

See it running

The code is in a private repository, but the app is open. It asks you to sign up with your Chess.com username, which is what tells it whose games these are.