Skip to content

Commit 07a51af

Browse files
committed
fixes
1 parent a83c9c2 commit 07a51af

File tree

9 files changed

+304
-8
lines changed

9 files changed

+304
-8
lines changed

modules/client_store.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def _normalize_holdings_map(raw: Any) -> Dict[str, Any]:
5959
return holdings
6060

6161

62-
def _normalize_account_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
62+
def _normalize_account_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
6363
account = Account.from_dict(payload)
6464
normalized = account.to_dict()
6565
extra = _split_extra(
@@ -85,7 +85,25 @@ def _normalize_account_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
8585
return normalized
8686

8787

88-
def _normalize_client_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
88+
def _account_fingerprint(account: models.Account) -> str:
89+
payload: Dict[str, Any] = {
90+
"name": account.name or "",
91+
"account_type": account.account_type or "",
92+
"ownership_type": account.ownership_type or "",
93+
"custodian": account.custodian or "",
94+
"tags": sorted(list(account.tags or [])),
95+
"tax_settings": account.tax_settings or {},
96+
"holdings_map": account.holdings_map or {},
97+
"lots": account.lots or {},
98+
"manual_holdings": account.manual_holdings or [],
99+
"extra": account.extra or {},
100+
"current_value": float(account.current_value or 0.0),
101+
"active_interval": account.active_interval or "1M",
102+
}
103+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
104+
105+
106+
def _normalize_client_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
89107
client = Client.from_dict(payload)
90108
normalized = client.to_dict()
91109
extra = _split_extra(
@@ -324,6 +342,73 @@ def update_account(self, client_ref: Any, account_ref: Any, payload: Dict[str, A
324342
db.refresh(account)
325343
return self._account_to_dict(account)
326344

345+
def find_duplicate_accounts(self) -> Dict[str, Any]:
346+
self.ensure_schema()
347+
with _session_scope(self._db) as db:
348+
duplicates: List[Dict[str, Any]] = []
349+
total_duplicates = 0
350+
client_count = 0
351+
for client in db.query(models.Client).all():
352+
groups: Dict[str, List[models.Account]] = {}
353+
for account in client.accounts:
354+
key = _account_fingerprint(account)
355+
groups.setdefault(key, []).append(account)
356+
has_duplicates = False
357+
for accounts in groups.values():
358+
if len(accounts) <= 1:
359+
continue
360+
has_duplicates = True
361+
accounts_sorted = sorted(accounts, key=lambda item: item.id or 0)
362+
keeper = accounts_sorted[0]
363+
dupes = accounts_sorted[1:]
364+
duplicates.append(
365+
{
366+
"client_id": client.client_uid or str(client.id),
367+
"client_name": client.name or "",
368+
"account_name": keeper.name or "",
369+
"account_type": keeper.account_type or "",
370+
"keep_account_id": keeper.account_uid or str(keeper.id),
371+
"duplicate_ids": [
372+
dup.account_uid or str(dup.id) for dup in dupes
373+
],
374+
"duplicate_count": len(dupes),
375+
}
376+
)
377+
total_duplicates += len(dupes)
378+
if has_duplicates:
379+
client_count += 1
380+
return {
381+
"count": total_duplicates,
382+
"clients": client_count,
383+
"details": duplicates,
384+
}
385+
386+
def remove_duplicate_accounts(self) -> Dict[str, Any]:
387+
self.ensure_schema()
388+
with _session_scope(self._db) as db:
389+
removed = 0
390+
client_count = 0
391+
for client in db.query(models.Client).all():
392+
groups: Dict[str, List[models.Account]] = {}
393+
for account in client.accounts:
394+
key = _account_fingerprint(account)
395+
groups.setdefault(key, []).append(account)
396+
removed_for_client = False
397+
for accounts in groups.values():
398+
if len(accounts) <= 1:
399+
continue
400+
accounts_sorted = sorted(accounts, key=lambda item: item.id or 0)
401+
dupes = accounts_sorted[1:]
402+
for account in dupes:
403+
db.delete(account)
404+
removed += 1
405+
removed_for_client = True
406+
if removed_for_client:
407+
client_count += 1
408+
if removed:
409+
db.commit()
410+
return {"removed": removed, "clients": client_count}
411+
327412
def _sync_accounts(
328413
self,
329414
db: Session,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.orm import sessionmaker
3+
4+
import core.database as database
5+
import core.db_management as db_management
6+
from core import models
7+
import modules.client_store as client_store
8+
9+
10+
def _setup_temp_db(tmp_path, monkeypatch):
11+
db_path = tmp_path / "clients.db"
12+
engine = create_engine(
13+
f"sqlite:///{db_path}", connect_args={"check_same_thread": False}
14+
)
15+
session_local = sessionmaker(
16+
autocommit=False, autoflush=False, bind=engine
17+
)
18+
monkeypatch.setattr(database, "engine", engine)
19+
monkeypatch.setattr(database, "SessionLocal", session_local)
20+
monkeypatch.setattr(db_management, "engine", engine)
21+
monkeypatch.setattr(client_store, "SessionLocal", session_local)
22+
return session_local
23+
24+
25+
def _seed_duplicates(session):
26+
client = models.Client(client_uid="c1", name="Dup Client")
27+
session.add(client)
28+
session.flush()
29+
account_payload = dict(
30+
name="Primary",
31+
account_type="Taxable",
32+
ownership_type="Individual",
33+
custodian="Fidelity",
34+
tags=["Core"],
35+
tax_settings={"jurisdiction": "US"},
36+
holdings_map={"AAPL": 1.0},
37+
lots={"AAPL": [{"qty": 1.0, "basis": 100.0, "timestamp": "2024-01-01T00:00:00"}]},
38+
manual_holdings=[],
39+
extra={"source": "seed"},
40+
current_value=100.0,
41+
active_interval="1M",
42+
client_id=client.id,
43+
)
44+
session.add(models.Account(account_uid="a1", **account_payload))
45+
session.add(models.Account(account_uid="a2", **account_payload))
46+
session.commit()
47+
48+
49+
def test_detect_duplicate_accounts(tmp_path, monkeypatch):
50+
session_local = _setup_temp_db(tmp_path, monkeypatch)
51+
db_management.create_db_and_tables()
52+
session = session_local()
53+
try:
54+
_seed_duplicates(session)
55+
store = client_store.DbClientStore(session)
56+
result = store.find_duplicate_accounts()
57+
assert result["count"] == 1
58+
assert result["clients"] == 1
59+
assert result["details"][0]["duplicate_count"] == 1
60+
finally:
61+
session.close()
62+
63+
64+
def test_cleanup_duplicate_accounts(tmp_path, monkeypatch):
65+
session_local = _setup_temp_db(tmp_path, monkeypatch)
66+
db_management.create_db_and_tables()
67+
session = session_local()
68+
try:
69+
_seed_duplicates(session)
70+
store = client_store.DbClientStore(session)
71+
result = store.remove_duplicate_accounts()
72+
assert result["removed"] == 1
73+
assert session.query(models.Account).count() == 1
74+
finally:
75+
session.close()

tests/test_web_api_clients.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.orm import sessionmaker
66

77
from core.database import Base
8+
from core import models
89
from web_api.app import app
910
from web_api.routes.clients import get_db
1011

@@ -110,3 +111,44 @@ def test_account_metadata_persists(client):
110111
assert "AAPL" in account["holdings"]
111112
assert account["lots"]["AAPL"][0]["basis"] == 100.0
112113
assert account["manual_holdings"][0]["total_value"] == 1234.5
114+
115+
116+
def test_duplicate_account_cleanup(client, session):
117+
dup_client = models.Client(client_uid="dup1", name="Dup Client")
118+
session.add(dup_client)
119+
session.flush()
120+
account_payload = dict(
121+
name="Primary",
122+
account_type="Taxable",
123+
ownership_type="Individual",
124+
custodian="Fidelity",
125+
tags=["Core"],
126+
tax_settings={"jurisdiction": "US"},
127+
holdings_map={"AAPL": 1.0},
128+
lots={"AAPL": [{"qty": 1.0, "basis": 100.0, "timestamp": "2024-01-01T00:00:00"}]},
129+
manual_holdings=[],
130+
extra={"source": "seed"},
131+
current_value=100.0,
132+
active_interval="1M",
133+
client_id=dup_client.id,
134+
)
135+
session.add(models.Account(account_uid="dup-a1", **account_payload))
136+
session.add(models.Account(account_uid="dup-a2", **account_payload))
137+
session.commit()
138+
139+
response = client.get("/api/clients/duplicates")
140+
assert response.status_code == 200
141+
payload = response.json()
142+
assert payload["count"] == 1
143+
144+
response = client.post(
145+
"/api/clients/duplicates/cleanup",
146+
json={"confirm": True},
147+
)
148+
assert response.status_code == 200
149+
cleaned = response.json()
150+
assert cleaned["removed"] == 1
151+
152+
response = client.get("/api/clients/duplicates")
153+
assert response.status_code == 200
154+
assert response.json()["count"] == 0

web/src/pages/Dashboard.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export default function Dashboard() {
105105
const [riskOpen, setRiskOpen] = useState(true);
106106
const [mapOpen, setMapOpen] = useState(true);
107107
const [feedOpen, setFeedOpen] = useState(true);
108+
const accentColor = "#48f1a6";
108109
const { paused } = useTrackerPause();
109110
const {
110111
data: trackerStream,
@@ -261,7 +262,7 @@ export default function Dashboard() {
261262
source: "tracker-points",
262263
paint: {
263264
"circle-radius": 6,
264-
"circle-color": "var(--green-500)",
265+
"circle-color": accentColor,
265266
"circle-opacity": 0.12
266267
}
267268
});
@@ -276,7 +277,7 @@ export default function Dashboard() {
276277
["get", "kind"],
277278
"ship",
278279
"#8892a0",
279-
"var(--green-500)"
280+
accentColor
280281
],
281282
"circle-opacity": 0.8
282283
}
@@ -412,7 +413,7 @@ export default function Dashboard() {
412413
(point) => Number.isFinite(point.lat) && Number.isFinite(point.lon)
413414
);
414415
points.slice(0, 200).forEach((point) => {
415-
const color = point.kind === "ship" ? "#8892a0" : "var(--green-500)";
416+
const color = point.kind === "ship" ? "#8892a0" : accentColor;
416417
L.circleMarker([point.lat, point.lon], {
417418
radius: 4,
418419
color,

web/src/pages/System.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ErrorBanner } from "../components/ui/ErrorBanner";
44
import { KpiCard } from "../components/ui/KpiCard";
55
import { SectionHeader } from "../components/ui/SectionHeader";
66
import {
7+
apiPost,
78
clearApiKey,
89
getApiKey,
910
setApiKey as setApiKeyLocal,
@@ -42,6 +43,7 @@ type DiagnosticsPayload = {
4243
trackers?: { warning_count?: number; count?: number };
4344
intel?: { news_cache?: { status?: string; items?: number; age_hours?: number | null } };
4445
clients?: { clients?: number; accounts?: number; holdings?: number; lots?: number };
46+
duplicates?: { accounts?: { count?: number; clients?: number } };
4547
reports?: { items?: number; status?: string };
4648
};
4749

@@ -66,6 +68,9 @@ export default function System() {
6668
: null
6769
].filter(Boolean) as string[];
6870

71+
const duplicateCount = data?.duplicates?.accounts?.count ?? 0;
72+
const duplicateClients = data?.duplicates?.accounts?.clients ?? 0;
73+
6974
const onNormalize = () => {
7075
// Implement normalization logic here
7176
};
@@ -74,6 +79,20 @@ export default function System() {
7479
// Implement clear cache logic here
7580
};
7681

82+
const onCleanupDuplicates = async () => {
83+
if (!duplicateCount) return;
84+
const confirmed = window.confirm(
85+
`Remove ${duplicateCount} duplicate account${duplicateCount === 1 ? '' : 's'} across ${duplicateClients} client${duplicateClients === 1 ? '' : 's'}? Originals will be preserved.`
86+
);
87+
if (!confirmed) return;
88+
try {
89+
await apiPost('/api/clients/duplicates/cleanup', { confirm: true });
90+
refresh();
91+
} catch (err) {
92+
alert(err instanceof Error ? err.message : 'Duplicate cleanup failed.');
93+
}
94+
};
95+
7796
const onSetApiKey = () => {
7897
const key = prompt("Enter API key:");
7998
if (key) {
@@ -106,6 +125,22 @@ export default function System() {
106125
<div className="mt-4">
107126
<ErrorBanner messages={errorMessages} onRetry={refresh} />
108127
</div>
128+
{duplicateCount > 0 ? (
129+
<div className="mt-4 rounded-xl border border-amber-400/30 bg-amber-400/10 p-4 text-sm text-amber-200">
130+
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
131+
<p>
132+
Duplicate accounts detected: {duplicateCount} across{' '}
133+
{duplicateClients} client{duplicateClients === 1 ? '' : 's'}.
134+
</p>
135+
<button
136+
onClick={onCleanupDuplicates}
137+
className="rounded-lg border border-amber-400/50 px-3 py-1 text-xs text-amber-100 hover:border-amber-300"
138+
>
139+
Remove duplicates
140+
</button>
141+
</div>
142+
</div>
143+
) : null}
109144
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-4">
110145
<KpiCard label="Clients" value={`${data?.clients?.clients ?? 0}`} tone="text-green-300" />
111146
<KpiCard label="Accounts" value={`${data?.clients?.accounts ?? 0}`} tone="text-slate-100" />

web/src/pages/Trackers.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export default function Trackers() {
146146
const [riskOpen, setRiskOpen] = useState(true);
147147
const [mapOpen, setMapOpen] = useState(true);
148148
const [feedOpen, setFeedOpen] = useState(true);
149+
const accentColor = "#48f1a6";
149150

150151
useEffect(() => {
151152
if (!paused) {
@@ -429,7 +430,7 @@ export default function Trackers() {
429430
source: "tracker-points",
430431
paint: {
431432
"circle-radius": 6,
432-
"circle-color": "var(--green-500)",
433+
"circle-color": accentColor,
433434
"circle-opacity": 0.12
434435
}
435436
});
@@ -444,7 +445,7 @@ export default function Trackers() {
444445
["get", "kind"],
445446
"ship",
446447
"#8892a0",
447-
"var(--green-500)"
448+
accentColor
448449
],
449450
"circle-opacity": 0.8
450451
}
@@ -476,7 +477,7 @@ export default function Trackers() {
476477
type: "line",
477478
source: "history-line",
478479
paint: {
479-
"line-color": "var(--green-500)",
480+
"line-color": accentColor,
480481
"line-width": 2,
481482
"line-opacity": 0.8
482483
}

web_api/diagnostics.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Dict, List, Optional
77

88
from modules.client_mgr.data_handler import DataHandler
9+
from modules.client_store import DbClientStore
910
from modules.market_data.trackers import GlobalTrackers
1011
from utils.system import SystemHost
1112

@@ -81,6 +82,11 @@ def client_counts() -> Dict[str, int]:
8182
}
8283

8384

85+
def duplicate_account_summary() -> Dict[str, object]:
86+
store = DbClientStore()
87+
return store.find_duplicate_accounts()
88+
89+
8490
def news_cache_info(max_age_hours: int = 6) -> Dict[str, Optional[object]]:
8591
path = os.path.join("data", "intel_news.json")
8692
if not os.path.exists(path):

0 commit comments

Comments
 (0)