Skip to content

Commit 710946a

Browse files
iamabhi9claude
andcommitted
Work out the split ratio instead of dropping the holding
A split multiplies your shares without any transaction recording it, so order history ends up showing more sold than were ever bought. FIFO has nothing to match those sales against, so the holding was dropped from booked profit entirely — safe, and on this portfolio that silently removed a third of it. The ratio is recoverable, because a split leaves fingerprints that have to agree: after adjusting, FIFO never runs short; the final share count equals what the broker reports; and the remaining shares' average cost matches the broker's own. The last one is what makes it trustworthy rather than arithmetic that happens to close — the broker's average is computed independently of anything here, so a wrong ratio must agree with a number it never saw. On two recovered holdings it agrees to the paisa. app/splits.py searches split date against plausible ratio and reports what fits. It found unambiguous answers for 13 of 34, listed 9 as ambiguous with their candidates, and 12 as unexplained by any single ratio — most likely two corporate actions or a merger with an odd conversion. Skipped holdings fall by a third and the gap between the two realised figures the app shows narrows by two thirds. Nothing is applied without --write, and even then only where exactly one ratio fits. A wrong ratio silently rewrites realised profit and, unlike a missing figure, does not announce itself. Confirmed ratios live in config.json beside ticker_aliases and demergers; removing one returns the holding to being skipped, which is the safe state. A first attempt inferred the ratio as (sold + held) / bought and resolved only 4. That is diluted by shares bought after the split, which is why the date is searched too. Two bugs found while testing. The helper assumed one lot layout, but the two FIFO walks in analytics.py store lots in different orders — now parameterised rather than silently assumed. And a split falling after the last trade in a ticker was never applied: the loop that should have handled it was a `pass` under a comment claiming otherwise, so a correct ratio would have been rejected for not matching the broker's count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a192433 commit 710946a

7 files changed

Lines changed: 396 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ funds match on ISIN; Paytm funds carry none, so the scheme name must reduce to e
5050
Direct/Growth scheme or it is skipped and reported. Report which funds moved and by how
5151
much. `--dry-run` writes nothing.
5252

53+
**"infer splits" / "fix splits"**`python3 app/splits.py` proposes ratios for holdings
54+
whose sales exceed their purchases, validated against the broker's own share count and
55+
average cost. Show what it found, including the ambiguous and unexplained ones, and get
56+
confirmation before running `--write`. A wrong ratio silently rewrites realised profit.
57+
5358
**"backup"**`python3 app/backup.py snapshot manual` (or POST /api/backup). Only the
5459
database file is copied; code lives in git, not Drive. Snapshots go to
5560
`<drive root>/trans`, auto-detected on macOS and Linux. Report the filename and

app/analytics.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,12 @@ def trades(c, ticker, market=None):
401401
rows = list(c.execute(
402402
"SELECT date,type,quantity,price,amount,fees,broker,asset FROM transactions"
403403
" WHERE ticker=?" + _ba(br) + " ORDER BY date,id", (ticker, *br)))
404+
sp = CFG.splits().get(ticker.upper(), [])
405+
applied = [0]
404406
lots, out = [], []
405407
for r in rows:
408+
if sp:
409+
_apply_splits(lots, sp, r["date"], applied, qi=1, pi=2) # [date, qty, price]
406410
q = r["quantity"] or 0.0
407411
px = r["price"] or 0.0
408412
rec = {"date": r["date"], "type": r["type"], "quantity": q, "price": px,
@@ -429,6 +433,8 @@ def trades(c, ticker, market=None):
429433
rec["realized"] = round(q * px - cost, 2) if need <= 1e-9 else None
430434
out.append(rec)
431435
qty = 0.0
436+
if sp: # a split after the final trade still moves the count
437+
_apply_splits(lots, sp, "9999-12-31", applied, qi=1, pi=2)
432438
for rec in out:
433439
if rec["type"] == "buy":
434440
qty += rec["quantity"]
@@ -528,6 +534,27 @@ def allocation(c, market=None):
528534
LONG_TERM_DAYS = 365
529535

530536

537+
def _apply_splits(lots, splits, upto, applied, qi=0, pi=1):
538+
# qi/pi say where quantity and price sit in a lot, because the two FIFO walks in this
539+
# file store lots in different orders. Worth collapsing one day; parameterised here
540+
# rather than silently assuming, which is how this first went wrong.
541+
"""Multiply held lots by any confirmed split falling on or before `upto`.
542+
543+
A split changes the share count without a transaction, so FIFO has nothing to match
544+
a later sale against and drops the holding entirely. Applying the ratio to the lots
545+
already open — and dividing their price by it, since the money paid did not change —
546+
puts the counts back in step. Confirmed ratios only: see app/splits.py, which proposes
547+
but never writes on its own.
548+
"""
549+
while applied[0] < len(splits) and splits[applied[0]]["date"] <= upto:
550+
f = splits[applied[0]]["ratio"]
551+
for lot in lots:
552+
lot[qi] *= f
553+
lot[pi] /= f
554+
applied[0] += 1
555+
return lots
556+
557+
531558
def _round_parts(v):
532559
"""Round a bucket for the wire, keeping realised equal to its two halves."""
533560
rs, rl = round(v["realized_short"], 2), round(v["realized_long"], 2)
@@ -558,9 +585,17 @@ def contributions(c, market=None):
558585

559586
# First pass: which tickers cannot be matched at all. Done up front so a ticker that
560587
# breaks in 2024 does not contribute a figure for 2021 that is then withdrawn.
588+
all_splits = CFG.splits()
561589
broken, held = set(), collections.defaultdict(list)
590+
seen_sp = collections.defaultdict(lambda: [0])
562591
for r in rows:
563592
t, q = r["ticker"], r["quantity"] or 0
593+
sp = all_splits.get(t)
594+
if sp:
595+
while seen_sp[t][0] < len(sp) and sp[seen_sp[t][0]]["date"] <= r["date"]:
596+
f = sp[seen_sp[t][0]]["ratio"]
597+
held[t] = [x * f for x in held[t]]
598+
seen_sp[t][0] += 1
564599
if r["type"] == "buy":
565600
held[t].append(q)
566601
else:
@@ -582,9 +617,12 @@ def _z():
582617

583618
by_m = collections.defaultdict(lambda: collections.defaultdict(_z))
584619
lots = collections.defaultdict(list) # ticker -> [[qty, price, date]]
620+
done = collections.defaultdict(lambda: [0])
585621
for r in rows:
586622
m, a = r["date"][:7], (r["asset"] or "equity")
587623
t, q, px = r["ticker"], r["quantity"] or 0, r["price"] or 0
624+
if all_splits.get(t):
625+
_apply_splits(lots[t], all_splits[t], r["date"], done[t])
588626
if r["type"] == "buy":
589627
by_m[m][a]["bought"] += q * px
590628
lots[t].append([q, px, r["date"]])

app/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@ def demergers():
4141
"""Optional {child: parent} overrides for demergers we do not ship."""
4242
return {str(k).upper(): str(v).upper()
4343
for k, v in (load().get("demergers") or {}).items()}
44+
def splits():
45+
"""{ticker: [{date, ratio}, ...]} — confirmed corporate actions that multiplied a
46+
share count. Populated by `python3 app/splits.py`, which proposes and never writes
47+
without confirmation."""
48+
out = {}
49+
for k, v in (load().get("splits") or {}).items():
50+
events = v if isinstance(v, list) else [v]
51+
clean = []
52+
for e in events:
53+
try:
54+
clean.append({"date": str(e["date"])[:10], "ratio": float(e["ratio"])})
55+
except (KeyError, TypeError, ValueError):
56+
continue
57+
if clean:
58+
out[str(k).upper()] = sorted(clean, key=lambda e: e["date"])
59+
return out
60+
61+
4462
def ticker_aliases():
4563
"""Optional {old_symbol: new_symbol} overrides for renames we do not ship."""
4664
return {str(k).upper(): str(v).upper()

app/splits.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Work out which corporate action multiplied a holding's share count.
4+
5+
Order history keeps the share counts as they were on the day. A split or bonus multiplies
6+
what you hold without any transaction recording it, so the tradebook ends up showing more
7+
shares sold than were ever bought. FIFO cannot match those sales against anything, so the
8+
ticker is dropped from booked profit entirely — which is safe, and on this portfolio hid a
9+
third of it.
10+
11+
A split leaves fingerprints, and this solves for the only ratio that fits all of them:
12+
13+
* after adjusting, FIFO never runs short of shares;
14+
* the final share count equals what the broker says is held;
15+
* the remaining shares' average cost matches the broker's own average.
16+
17+
The last one is what makes this trustworthy rather than arithmetic that happens to close.
18+
The broker's average is computed independently of anything here, so a wrong ratio has to
19+
agree with a number it never saw.
20+
21+
python3 app/splits.py propose ratios, write nothing
22+
python3 app/splits.py --write add the unambiguous ones to config.json
23+
24+
NOTHING is applied without --write, and even then only where exactly one ratio fits. A
25+
wrong ratio silently rewrites realised profit, and unlike a missing figure a wrong one
26+
does not announce itself.
27+
"""
28+
import os, sys, json, datetime as dt
29+
30+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31+
import db as D
32+
import config as CFG
33+
34+
# Ratios seen in Indian corporate actions: 1:1 bonus is 2x, 1:2 bonus 1.5x, and so on.
35+
RATIOS = [1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 20, 50, 100]
36+
AVG_TOLERANCE = 0.05 # broker's average cost must agree within 5%
37+
38+
39+
def _rows(c, ticker):
40+
return list(c.execute(
41+
"SELECT date,type,quantity,price FROM transactions WHERE ticker=?"
42+
" ORDER BY date,id", (ticker,)))
43+
44+
45+
def replay(rows, events):
46+
"""FIFO with `events` applied. -> (ok, held_quantity, remaining_cost).
47+
48+
ok is False the moment a sale exceeds the shares on hand, which is the signal that
49+
the ratio being tried is wrong (or that there is no ratio).
50+
"""
51+
lots, applied = [], 0
52+
for r in rows:
53+
while applied < len(events) and events[applied]["date"] <= r["date"]:
54+
f = events[applied]["ratio"]
55+
for lot in lots:
56+
lot[0] *= f
57+
lot[1] /= f
58+
applied += 1
59+
q, px = r["quantity"] or 0.0, r["price"] or 0.0
60+
if r["type"] == "buy":
61+
lots.append([q, px])
62+
continue
63+
if r["type"] != "sell":
64+
continue
65+
need = q
66+
while need > 1e-6 and lots:
67+
take = min(need, lots[0][0])
68+
lots[0][0] -= take
69+
need -= take
70+
if lots[0][0] <= 1e-6:
71+
lots.pop(0)
72+
if need > 1e-6:
73+
return False, 0.0, 0.0
74+
# A split can fall after the last trade in a ticker — you stop trading it, then it
75+
# splits. Those events never come up in the loop above, so apply them here or the
76+
# final share count will not match what the broker reports and the right ratio gets
77+
# rejected as wrong.
78+
while applied < len(events):
79+
f = events[applied]["ratio"]
80+
for lot in lots:
81+
lot[0] *= f
82+
lot[1] /= f
83+
applied += 1
84+
return True, sum(l[0] for l in lots), sum(l[0] * l[1] for l in lots)
85+
86+
87+
def infer(rows, held_qty, broker_avg):
88+
"""-> (ratios_that_fit, candidate_dates). One ratio means it is safe to use."""
89+
if not any(r["type"] == "buy" for r in rows):
90+
return [], []
91+
dates = sorted({r["date"] for r in rows})
92+
hits = []
93+
for i, sd in enumerate(dates):
94+
for ratio in RATIOS:
95+
ok, q, cost = replay(rows, [{"date": sd, "ratio": ratio}])
96+
if not ok or abs(q - held_qty) > 0.01:
97+
continue
98+
if held_qty > 0 and broker_avg:
99+
avg = cost / q
100+
if abs(avg - broker_avg) / broker_avg > AVG_TOLERANCE:
101+
continue
102+
hits.append((sd, ratio))
103+
return sorted({r for _, r in hits}), sorted({d for d, _ in hits})
104+
105+
106+
def unmatched(c):
107+
"""Tickers whose sales exceed their purchases — the ones FIFO has to skip."""
108+
out = []
109+
for r in c.execute("SELECT DISTINCT ticker FROM transactions ORDER BY ticker"):
110+
t = r["ticker"]
111+
rows = _rows(c, t)
112+
ok, _, _ = replay(rows, [])
113+
if not ok:
114+
out.append(t)
115+
return out
116+
117+
118+
def main():
119+
write = "--write" in sys.argv
120+
c = D.connect()
121+
pos = {r["ticker"]: (r["quantity"], r["avg_cost"]) for r in
122+
c.execute("SELECT ticker,quantity,avg_cost FROM positions")}
123+
known = CFG.splits()
124+
125+
todo = [t for t in unmatched(c) if t not in known]
126+
if not todo:
127+
print("every holding's sales match its purchases — nothing to infer.")
128+
return 0
129+
130+
print(f"{len(todo)} holding(s) sell more shares than they bought.\n"
131+
"That is what a split or bonus looks like in order history.\n")
132+
found, ambiguous, none = {}, [], []
133+
for t in todo:
134+
rows = _rows(c, t)
135+
q, avg = pos.get(t, (0.0, None))
136+
ratios, dates = infer(rows, q, avg)
137+
if len(ratios) == 1:
138+
# earliest date that works — a split cannot precede the shares it multiplied
139+
found[t] = {"date": dates[0], "ratio": ratios[0]}
140+
check = "and the broker's average cost agrees" if q > 0 else \
141+
"share count closes exactly (nothing held, so no average to check)"
142+
print(f" {t:<13} {ratios[0]:g}x on or before {dates[0]} {check}")
143+
elif ratios:
144+
ambiguous.append((t, ratios))
145+
else:
146+
none.append(t)
147+
148+
if ambiguous:
149+
print(f"\n {len(ambiguous)} ambiguous — several ratios fit equally well, so"
150+
" picking one would be a guess:")
151+
for t, rs in ambiguous:
152+
print(f" {t:<13} could be {', '.join(f'{r:g}x' for r in rs)}")
153+
if none:
154+
print(f"\n {len(none)} unexplained by any single ratio — most likely two"
155+
" corporate actions,\n a merger with an odd conversion, or purchases"
156+
" missing from the tradebook:")
157+
print(" " + ", ".join(none))
158+
159+
if not found:
160+
print("\nnothing unambiguous to write.")
161+
return 0
162+
if not write:
163+
print(f"\n{len(found)} ratio(s) determined. Nothing written — re-run with --write"
164+
" to add them\nto config.json, or add them by hand if you would rather"
165+
" check each first.")
166+
return 0
167+
168+
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
169+
"config.json")
170+
cfg = json.load(open(path)) if os.path.exists(path) else {}
171+
cfg.setdefault("splits", {})
172+
for t, ev in found.items():
173+
cfg["splits"][t] = [ev]
174+
with open(path, "w") as fh:
175+
json.dump(cfg, fh, indent=2)
176+
fh.write("\n")
177+
CFG.load(reload=True)
178+
print(f"\nwrote {len(found)} split(s) to config.json.")
179+
print("Re-check them against the corporate action if a figure looks wrong; removing"
180+
" an entry\nputs the holding back to being skipped, which is the safe state.")
181+
return 0
182+
183+
184+
if __name__ == "__main__":
185+
sys.exit(main())

config.example.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,6 @@
6565
}
6666
},
6767
"ticker_aliases": {},
68-
"demergers": {}
68+
"demergers": {},
69+
"splits": {}
6970
}

docs/IMPORTING.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,36 @@ quantities drift and some tickers appear to sell more than they ever bought. Thi
173173
broker's current share count. It does mean derived share counts are marked unreliable —
174174
`Held` shows `~` and the elapsed span instead of true holding days.
175175

176+
### 3a. Splits can be worked out, where the evidence allows
177+
178+
A skipped holding is a holding missing from booked profit, so it is worth recovering the
179+
ones that can be recovered.
180+
181+
```bash
182+
python3 app/splits.py # propose ratios, write nothing
183+
python3 app/splits.py --write # record the unambiguous ones in config.json
184+
```
185+
186+
A split leaves fingerprints, and the tool solves for the only ratio that fits all of
187+
them: after adjusting, FIFO never runs short; the final share count equals what the
188+
broker reports; and the remaining shares' average cost matches the broker's own average.
189+
That last check is what makes it trustworthy — the broker's average is computed
190+
independently of anything here, so a wrong ratio has to agree with a number it never saw.
191+
192+
Ambiguous cases are listed and left alone. So are holdings that no single ratio explains,
193+
which usually means two corporate actions or a merger with an odd conversion. Nothing is
194+
written without `--write`, and even then only where exactly one ratio fits, because a
195+
wrong ratio silently rewrites realised profit and — unlike a missing figure — does not
196+
announce itself.
197+
198+
Confirmed splits live in `config.json` and can be edited or removed by hand:
199+
200+
```json
201+
"splits": { "RELIANCE": [{"date": "2025-03-06", "ratio": 2}] }
202+
```
203+
204+
Removing an entry puts the holding back to being skipped, which is the safe state.
205+
176206
### 4. Demerged shares arrive with no cost
177207

178208
A demerger should split the parent's cost basis between parent and children. Zerodha

0 commit comments

Comments
 (0)