Skip to content

Scope a positions snapshot to its own broker - #5

Merged
iamabhi9 merged 2 commits into
mainfrom
fix/position-snapshot-scoping
Sep 3, 2026
Merged

Scope a positions snapshot to its own broker#5
iamabhi9 merged 2 commits into
mainfrom
fix/position-snapshot-scoping

Conversation

@iamabhi9

@iamabhi9 iamabhi9 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

A sync of one market deleted every holding belonging to the others.

The bug

def upsert_positions(c, rows, asof=None):
    c.execute("DELETE FROM positions")     # every broker, not just this one

Correct when Robinhood was the only source. Silent data loss the moment a second market
existed: a US sync wiped all Zerodha and Paytm positions.

It also never stamped broker or currency on the rows it inserted, so anything it wrote
defaulted to the schema's robinhood/USD.

Why nothing caught it

The figures checked after a US sync were the US figures, and those stayed perfectly
correct. The other market simply had no positions left — which renders as an empty market,
not as an error. Every test and every manual check looked at the market that still worked.

The fix

Delete only the rows being replaced, and stamp broker and currency on the inserts.

Tests

Three regressions in tests/test_ingest.py:

  • a snapshot from one broker leaves the others intact
  • it does still replace its own broker's rows
  • broker and currency are stamped
$ python3 run_tests.py
PASSED — 105 unit tests, plus the XIRR solver suite

$ python3 scripts/check_clean.py
clean

Recovery note for anyone who hit this

Positions are a snapshot, not history — transactions are untouched, so nothing is
permanently lost. Re-sync the affected market to repopulate. Paytm positions are derived
from transactions and rebuild with ingest._rebuild_paytm_positions().

iamabhi9 and others added 2 commits September 3, 2026 15:58
Prices were never updated except by a broker sync, and nothing said so.

`app/navs.py` fetches AMFI's daily NAV file — public, no key, nothing
personal sent — and marks every mutual fund position. This matters most
for Paytm, which reports no NAV at all: those funds were marked at the
NAV of their last transaction, and one was 24 days stale. Refreshing
corrected India from 97,31,673 to 97,20,028.

Matching refuses to guess. A NAV on the wrong fund is worse than a stale
one, because a stale price is visibly stale and a wrong one is not. Funds
with an ISIN match on it. Paytm funds have none, so the scheme name must
reduce to exactly one Direct/Growth scheme after filler words are
dropped — which correctly matched "HDFC Index Fund - BSE Sensex Plan" to
AMFI's "HDFC BSE Sensex Index Fund" despite the word order, and refuses
anything ambiguous. Regular plans and IDCW options are excluded, since
an IDCW NAV would permanently understate a growth holding. Resolved
ISINs are stored so a name is matched once and can be audited.

"Data through" measured the wrong thing. It reported the last transaction
date, so a trade this morning made the header look healthy while fund
NAVs were a month old. Price age is now reported per asset class as the
OLDEST mark in each group, not the newest — one fund priced today does
not make the fund holdings current — shown beside the transaction date
and bannered past a week.

The quotes table was dead: written by upsert_quotes, read by nothing, 0
rows. It now backs pricing. A quote is used only when dated later than
the broker snapshot, so a stale quote can never override a fresh sync.

Writing the tests found a real bug. results() had a second pricing path
for holdings with no transactions — which is every mutual fund — that
read positions.price directly and ignored quotes entirely. So the funds
this feature exists to reprice were the one group it would not have
reached. Both paths now go through _positions().

22 new tests: feed parsing, the matcher's refusals, and that a stale
quote loses to a fresh broker price.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sync of one market deleted every holding belonging to the others.

upsert_positions issued `DELETE FROM positions` with no filter. That was
correct when Robinhood was the only source, and became silent data loss
the moment a second market existed — the snapshot is authoritative for
its own broker and says nothing about anyone else's. It now deletes only
the rows it is replacing, and stamps broker and currency on what it
writes, which it also was not doing.

Nothing caught this because the figures checked after a US sync were the
US figures, and those stayed perfectly correct. The other market simply
had no positions left, which reads as an empty market rather than an
error.

Three regression tests: a snapshot from one broker leaves the others
intact, it does still replace its own rows, and broker and currency are
stamped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@iamabhi9
iamabhi9 merged commit b06a25f into main Sep 3, 2026
3 checks passed
@iamabhi9
iamabhi9 deleted the fix/position-snapshot-scoping branch September 3, 2026 21:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant