diff --git a/README.md b/README.md index 07bd143..33da64e 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ A web dashboard for viewing your Trading212 portfolio positions at a glance. - Buy history tooltips: hover over stock name to see purchase dates and quantities - Historical FX rate conversion for foreign-currency orders - Incremental history caching: orders and dividends cached to disk - - Local: `~/.cache/t2/` — systemd fallback: `/var/cache/t2/` (owner-only permissions) + - Location: `$CACHE_DIRECTORY` (set by systemd `CacheDirectory=t2`), then `~/.cache/t2/`, then `/var/cache/t2/` (owner-only permissions) - Cold start: fetches all pages from API, saves to disk - Warm start: fetches only new data by finding overlap with cache (typically 1 API call) + - Optional `backup_dir`: mirror cache to a Dropbox/OneDrive-synced folder for off-machine durability and rehydration after a fresh install +- Per-ticker reconciliation: works around a Trading212 API bug where the unfiltered paginated `/equity/history/orders` endpoint silently drops orders in certain time windows (the same orders are returned when queried with `?ticker=X`). After each refresh, t2 detects closed positions with suspiciously zero recovered amounts and fetches them per-ticker to fill in the gaps. - Sortable columns (click headers to toggle ascending/descending) - Default sort by market value descending - Auto-refresh every 15 minutes + manual refresh button per row @@ -104,6 +106,22 @@ The config file is searched in order: | `refresh_interval` | `15m` | How often to refresh positions | | `listen` | `:8080` | HTTP server listen address | | `finnhub_api_key` | (optional) | [Finnhub](https://finnhub.io) API key for US stock fundamentals (free tier) | +| `backup_dir` | (optional) | Mirror cache to a second directory (e.g. a Dropbox/OneDrive-synced folder) for off-machine durability | + +### Running as a systemd service + +If you install t2 as a system service running under a dedicated `User=t2`, add the following to your unit file so the cache lives in a directory the service user can actually write to: + +```ini +[Service] +User=t2 +Group=t2 +CacheDirectory=t2 +StateDirectory=t2 +ExecStart=/usr/bin/t2 +``` + +`CacheDirectory=t2` makes systemd create `/var/cache/t2/` owned by user `t2` on each start and exposes the path via `$CACHE_DIRECTORY`, which t2 prefers over the user's home (`/nonexistent` for system users). Without this, t2 silently falls back to writing the cache to the working directory (`/`) where it can't be persisted, causing every restart to re-fetch ~1,100 orders from Trading212 (a ~4-minute warmup). ## Architecture diff --git a/cmd/t2/main.go b/cmd/t2/main.go index 0e4d679..c770807 100644 --- a/cmd/t2/main.go +++ b/cmd/t2/main.go @@ -31,7 +31,7 @@ func main() { } log.Println("loading instrument and exchange metadata...") - svc, err := portfolio.NewService(client, fundsSvc) + svc, err := portfolio.NewService(client, fundsSvc, cfg.BackupDir) if err != nil { log.Fatalf("portfolio service: %v", err) } diff --git a/config.example.yaml b/config.example.yaml index 82d6174..08e81bd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,3 +15,13 @@ base_url: "https://live.trading212.com/api/v0" # Optional: Finnhub API key for US stock fundamentals (free at https://finnhub.io) # Without this, all fundamentals are fetched from Yahoo Finance # finnhub_api_key: "your-finnhub-key-here" + +# Optional: mirror the orders.json / dividends.json cache files to a second +# location after every successful fetch. Point this at a Dropbox / OneDrive / +# rclone-synced directory to keep an off-machine archive of your trading +# history — useful because Trading212 is known to drop orders from their +# paginated history endpoint and may eventually purge older records entirely. +# At startup, if the primary cache is missing but the backup exists, t2 +# rehydrates from it (so a fresh DietPi install picks up where the old one +# left off). Leave commented out to disable. +# backup_dir: "/home/dietpi/Dropbox/t2" diff --git a/internal/config/config.go b/internal/config/config.go index 777b1b1..ccad04e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Listen string `yaml:"listen"` BaseURL string `yaml:"base_url"` FinnhubAPIKey string `yaml:"finnhub_api_key"` + BackupDir string `yaml:"backup_dir"` } // rawConfig is used for YAML unmarshaling since time.Duration @@ -28,6 +29,7 @@ type rawConfig struct { Listen string `yaml:"listen"` BaseURL string `yaml:"base_url"` FinnhubAPIKey string `yaml:"finnhub_api_key"` + BackupDir string `yaml:"backup_dir"` } // Load reads the configuration file and returns a validated Config. @@ -54,6 +56,7 @@ func Load() (*Config, error) { Listen: raw.Listen, BaseURL: raw.BaseURL, FinnhubAPIKey: raw.FinnhubAPIKey, + BackupDir: raw.BackupDir, } // Parse refresh interval with default. diff --git a/internal/portfolio/history_cache.go b/internal/portfolio/history_cache.go index ae81cf1..b02d1ce 100644 --- a/internal/portfolio/history_cache.go +++ b/internal/portfolio/history_cache.go @@ -39,19 +39,31 @@ func dividendKey(item trading212.DividendHistoryItem) string { } // cacheDir returns the t2 cache directory path. -// Prefers ~/.cache/t2 for local users, falls back to /var/cache/t2 for system services. +// Order: $CACHE_DIRECTORY (set by systemd CacheDirectory=), $HOME/.cache/t2, +// then /var/cache/t2 as a last resort. func cacheDir() string { - if home, err := os.UserHomeDir(); err == nil { + // systemd sets CACHE_DIRECTORY when the unit has CacheDirectory=t2. + // This is the right answer for service installations. + if dir := os.Getenv("CACHE_DIRECTORY"); dir != "" { + if err := os.MkdirAll(dir, 0700); err == nil { + return dir + } + log.Printf("history-cache: CACHE_DIRECTORY=%q is not writable", dir) + } + // Honor $HOME but skip the well-known /nonexistent placeholder used for + // system users — MkdirAll would silently succeed if a stale directory + // existed and silently fail otherwise. + if home, err := os.UserHomeDir(); err == nil && home != "" && home != "/nonexistent" { dir := filepath.Join(home, ".cache", "t2") if err := os.MkdirAll(dir, 0700); err == nil { return dir } } - // Fallback for systemd services where $HOME is /nonexistent. const fallback = "/var/cache/t2" if err := os.MkdirAll(fallback, 0700); err == nil { return fallback } + log.Printf("history-cache: WARNING — no writable cache directory found; cache disabled") return "" } @@ -71,6 +83,27 @@ func loadOrdersCache(path string) []trading212.OrderHistoryItem { return cache.Items } +// loadOrdersCacheWithFallback reads the primary cache and falls back to the +// backup_dir copy if the primary is missing. Used at startup so a fresh +// install (or wiped cache directory) can rehydrate from the user's cloud +// backup without re-fetching everything from Trading212. +func loadOrdersCacheWithFallback(path, backupDir string) []trading212.OrderHistoryItem { + if items := loadOrdersCache(path); items != nil { + return items + } + if backupDir == "" { + return nil + } + bp := filepath.Join(backupDir, "orders.json") + items := loadOrdersCache(bp) + if items != nil { + log.Printf("history-cache: rehydrated %d orders from backup_dir (%s)", len(items), bp) + // Seed the primary cache so subsequent loads don't re-touch the backup. + saveOrdersCache(path, items) + } + return items +} + // saveOrdersCache writes the orders cache to disk. func saveOrdersCache(path string, items []trading212.OrderHistoryItem) { if path == "" { @@ -109,6 +142,23 @@ func loadDividendsCache(path string) []trading212.DividendHistoryItem { return cache.Items } +// loadDividendsCacheWithFallback mirrors loadOrdersCacheWithFallback. +func loadDividendsCacheWithFallback(path, backupDir string) []trading212.DividendHistoryItem { + if items := loadDividendsCache(path); items != nil { + return items + } + if backupDir == "" { + return nil + } + bp := filepath.Join(backupDir, "dividends.json") + items := loadDividendsCache(bp) + if items != nil { + log.Printf("history-cache: rehydrated %d dividends from backup_dir (%s)", len(items), bp) + saveDividendsCache(path, items) + } + return items +} + // saveDividendsCache writes the dividends cache to disk. func saveDividendsCache(path string, items []trading212.DividendHistoryItem) { if path == "" { @@ -131,10 +181,30 @@ func saveDividendsCache(path string, items []trading212.DividendHistoryItem) { log.Printf("history-cache: saved %d dividends to disk", len(items)) } +// mirrorOrders writes the same orders cache to a backup location (e.g. a +// Dropbox- or rclone-synced directory). Errors are logged but not returned — +// the primary cache write is what matters for runtime correctness. +func mirrorOrders(backupDir string, items []trading212.OrderHistoryItem) { + if backupDir == "" { + return + } + saveOrdersCache(filepath.Join(backupDir, "orders.json"), items) +} + +// mirrorDividends mirrors the dividends cache to a backup location. +func mirrorDividends(backupDir string, items []trading212.DividendHistoryItem) { + if backupDir == "" { + return + } + saveDividendsCache(filepath.Join(backupDir, "dividends.json"), items) +} + // fetchOrdersIncremental fetches order history incrementally, using the cache // to avoid re-fetching pages that overlap with already-known data. -func fetchOrdersIncremental(client *trading212.Client, cachePath string) ([]trading212.OrderHistoryItem, error) { - cached := loadOrdersCache(cachePath) +// If backupDir is non-empty, the cache is mirrored there after each successful +// save and used as a fallback rehydration source if the primary cache is missing. +func fetchOrdersIncremental(client *trading212.Client, cachePath, backupDir string) ([]trading212.OrderHistoryItem, error) { + cached := loadOrdersCacheWithFallback(cachePath, backupDir) // Build set of known keys from cache. known := make(map[string]bool, len(cached)) @@ -180,14 +250,17 @@ func fetchOrdersIncremental(client *trading212.Client, cachePath string) ([]trad // Prepend new items (newest-first) to cached items. merged := append(newItems, cached...) saveOrdersCache(cachePath, merged) + mirrorOrders(backupDir, merged) log.Printf("history-cache: %d new orders fetched, total %d", len(newItems), len(merged)) return merged, nil } // fetchDividendsIncremental fetches dividend history incrementally, using the cache // to avoid re-fetching pages that overlap with already-known data. -func fetchDividendsIncremental(client *trading212.Client, cachePath string) ([]trading212.DividendHistoryItem, error) { - cached := loadDividendsCache(cachePath) +// If backupDir is non-empty, the cache is mirrored there after each successful +// save and used as a fallback rehydration source if the primary cache is missing. +func fetchDividendsIncremental(client *trading212.Client, cachePath, backupDir string) ([]trading212.DividendHistoryItem, error) { + cached := loadDividendsCacheWithFallback(cachePath, backupDir) known := make(map[string]bool, len(cached)) for _, item := range cached { @@ -231,6 +304,7 @@ func fetchDividendsIncremental(client *trading212.Client, cachePath string) ([]t merged := append(newItems, cached...) saveDividendsCache(cachePath, merged) + mirrorDividends(backupDir, merged) log.Printf("history-cache: %d new dividends fetched, total %d", len(newItems), len(merged)) return merged, nil } diff --git a/internal/portfolio/service.go b/internal/portfolio/service.go index 639c7f7..b2df685 100644 --- a/internal/portfolio/service.go +++ b/internal/portfolio/service.go @@ -2,6 +2,7 @@ package portfolio import ( "log" + "os" "path/filepath" "sort" "strings" @@ -27,21 +28,36 @@ type Service struct { summaryMu sync.RWMutex summary *Summary // cached summary for cheap page polls - ordersCachePath string // ~/.cache/t2/orders.json - dividendsCachePath string // ~/.cache/t2/dividends.json + ordersCachePath string // primary cache, e.g. ~/.cache/t2/orders.json + dividendsCachePath string // primary cache, e.g. ~/.cache/t2/dividends.json + backupDir string // optional second location (e.g. a Dropbox-synced folder) } // NewService creates a new portfolio service and loads initial metadata. // It retries up to 5 times with 30s backoff if rate-limited on startup. -func NewService(client *trading212.Client, fundsSvc *fundamentals.Service) (*Service, error) { +// backupDir, if non-empty, is a second location where orders.json and +// dividends.json are mirrored after each successful fetch — typically a +// path inside a Dropbox/OneDrive/rclone-synced directory, so the user's +// trading history survives a wiped cache or a fresh DietPi install. +func NewService(client *trading212.Client, fundsSvc *fundamentals.Service, backupDir string) (*Service, error) { s := &Service{ - client: client, - fundsSvc: fundsSvc, - returns: make(map[string]tickerReturns), + client: client, + fundsSvc: fundsSvc, + returns: make(map[string]tickerReturns), + backupDir: backupDir, } if dir := cacheDir(); dir != "" { s.ordersCachePath = filepath.Join(dir, "orders.json") s.dividendsCachePath = filepath.Join(dir, "dividends.json") + log.Printf("history-cache: using %s", dir) + } + if backupDir != "" { + if err := os.MkdirAll(backupDir, 0700); err != nil { + log.Printf("history-cache: backup_dir %q is not writable: %v (backup disabled)", backupDir, err) + s.backupDir = "" + } else { + log.Printf("history-cache: mirroring to backup_dir %s", backupDir) + } } var err error for attempt := 1; attempt <= 5; attempt++ { @@ -77,13 +93,15 @@ func (s *Service) StartReturnsRefresh(interval time.Duration) { // Initial fetch after a short delay to let metadata settle. time.Sleep(5 * time.Second) s.refreshReturns() - s.refreshSummary() // update summary now that returns are available + s.reconcileMissingReturns() // workaround for T212 pagination gaps + s.refreshSummary() // update summary now that returns are available s.tryFundamentalsRefresh() ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { s.refreshReturns() + s.reconcileMissingReturns() s.refreshSummary() } }() @@ -212,74 +230,219 @@ func toGBP(item trading212.OrderHistoryItem, rates map[string][]fxRateEntry) flo return wi.NetValue } -// refreshReturns fetches order and dividend history and updates the cache. -func (s *Service) refreshReturns() { +// processIntoReturns folds a slice of orders + dividends into the per-ticker +// returns map. Pure function — no I/O. Extracted so reconcileMissingReturns +// can re-run it on an updated cache without re-fetching from the API. +func processIntoReturns(orders []trading212.OrderHistoryItem, dividends []trading212.DividendHistoryItem) map[string]tickerReturns { returns := make(map[string]tickerReturns) - - orders, err := fetchOrdersIncremental(s.client, s.ordersCachePath) - if err != nil { - log.Printf("order history fetch failed: %v", err) - } else { - fxRates := buildFxRateLookup(orders) - for _, item := range orders { - // Skip stock splits — they are zero-sum internal rebookings. - if item.Fill.Type == "STOCK_SPLIT" { - continue - } - // Skip unfilled orders (safety net). - if item.Fill.WalletImpact.Currency == "" { - continue + fxRates := buildFxRateLookup(orders) + for _, item := range orders { + // Skip stock splits — they are zero-sum internal rebookings. + if item.Fill.Type == "STOCK_SPLIT" { + continue + } + // Skip unfilled orders (the cancelled-order shape T212 returns has + // no fill object at all, which unmarshals to all-zero values). + if item.Fill.WalletImpact.Currency == "" { + continue + } + netGBP := toGBP(item, fxRates) + tr := returns[item.Order.Ticker] + switch item.Order.Side { + case "BUY": + tr.totalBuyCost += netGBP + fillDate := item.Fill.FilledAt + if len(fillDate) >= 10 { + fillDate = fillDate[:10] } - netGBP := toGBP(item, fxRates) - tr := returns[item.Order.Ticker] - switch item.Order.Side { - case "BUY": - tr.totalBuyCost += netGBP - fillDate := item.Fill.FilledAt - if len(fillDate) >= 10 { - fillDate = fillDate[:10] - } - if tr.firstBought == "" || fillDate < tr.firstBought { - tr.firstBought = fillDate - } - tr.buyHistory = append(tr.buyHistory, BuyEntry{ - Date: fillDate, - Quantity: item.Fill.Quantity, - }) - case "SELL": - tr.totalSellProceeds += netGBP + if tr.firstBought == "" || fillDate < tr.firstBought { + tr.firstBought = fillDate } - returns[item.Order.Ticker] = tr - } - // Sort each ticker's buy history oldest-first. - for ticker, tr := range returns { - sort.Slice(tr.buyHistory, func(i, j int) bool { - return tr.buyHistory[i].Date < tr.buyHistory[j].Date + tr.buyHistory = append(tr.buyHistory, BuyEntry{ + Date: fillDate, + Quantity: item.Fill.Quantity, }) - returns[ticker] = tr + case "SELL": + tr.totalSellProceeds += netGBP } + returns[item.Order.Ticker] = tr + } + // Sort each ticker's buy history oldest-first. + for ticker, tr := range returns { + sort.Slice(tr.buyHistory, func(i, j int) bool { + return tr.buyHistory[i].Date < tr.buyHistory[j].Date + }) + returns[ticker] = tr + } + for _, item := range dividends { + tr := returns[item.Ticker] + tr.totalDividends += item.Amount + returns[item.Ticker] = tr + } + return returns +} + +// refreshReturns fetches order and dividend history and updates the cache. +func (s *Service) refreshReturns() { + orders, err := fetchOrdersIncremental(s.client, s.ordersCachePath, s.backupDir) + if err != nil { + log.Printf("order history fetch failed: %v", err) + } else { log.Printf("loaded %d historical orders", len(orders)) + warnIfPaginationGaps(orders) } time.Sleep(2 * time.Second) // respect rate limits between endpoints - dividends, err := fetchDividendsIncremental(s.client, s.dividendsCachePath) + dividends, err := fetchDividendsIncremental(s.client, s.dividendsCachePath, s.backupDir) if err != nil { log.Printf("dividend history fetch failed: %v", err) } else { - for _, item := range dividends { - tr := returns[item.Ticker] - tr.totalDividends += item.Amount - returns[item.Ticker] = tr - } log.Printf("loaded %d historical dividends", len(dividends)) } + returns := processIntoReturns(orders, dividends) + s.returnsMu.Lock() s.returns = returns s.returnsMu.Unlock() } +// warnIfPaginationGaps logs a warning if the unfiltered orders stream has +// gaps > 30 days between consecutive fills. T212's paginated history +// endpoint silently drops orders in certain windows for some accounts — see +// reconcileMissingReturns for the workaround. Detecting the gap up front +// gives the user (and future maintainers) a heads-up that the unfiltered +// stream is incomplete and that reconciliation is doing work that should +// not have been necessary. +func warnIfPaginationGaps(orders []trading212.OrderHistoryItem) { + dates := make([]string, 0, len(orders)) + for _, o := range orders { + if o.Fill.FilledAt != "" { + dates = append(dates, o.Fill.FilledAt) + } + } + if len(dates) < 2 { + return + } + sort.Strings(dates) + const gapThresholdDays = 30 + for i := 1; i < len(dates); i++ { + ta, errA := time.Parse(time.RFC3339, dates[i-1]) + tb, errB := time.Parse(time.RFC3339, dates[i]) + if errA != nil || errB != nil { + continue + } + gap := tb.Sub(ta).Hours() / 24 + if gap > gapThresholdDays { + log.Printf("WARNING: unfiltered order history has a %.0f-day gap between %s and %s — Trading212 pagination is dropping orders; reconciliation will fetch them per-ticker", + gap, dates[i-1][:10], dates[i][:10]) + } + } +} + +// reconcileMissingReturns is a workaround for a Trading212 API bug where the +// unfiltered /equity/history/orders endpoint silently drops orders in +// certain time windows for some accounts. The same orders ARE returned when +// queried with the ?ticker=X filter. +// +// Strategy: after the unfiltered fetch builds the returns map, identify +// "closed-position candidates" — tickers with BUYs in cache but no SELLs +// and no dividends, and not in the current open positions. These are +// almost certainly stocks the user fully sold but where the SELLs landed +// inside a pagination gap. For each such ticker, do a per-ticker fetch +// (cheap, usually one page) and merge any new orders into the cache. +// +// Cost is bounded by the number of mismatched closed positions, not the +// portfolio size — typically a handful, sometimes zero. +func (s *Service) reconcileMissingReturns() { + if s.ordersCachePath == "" { + return // no persistent cache, nothing to reconcile against + } + positions, err := s.client.GetPositions() + if err != nil { + log.Printf("reconcile: GetPositions failed: %v (skipping)", err) + return + } + openTickers := make(map[string]bool, len(positions)) + for _, p := range positions { + openTickers[p.Ticker] = true + } + + s.returnsMu.RLock() + var candidates []string + for ticker, tr := range s.returns { + if openTickers[ticker] { + continue + } + if tr.totalBuyCost == 0 { + continue + } + // A closed position with zero recovered (no sells, no divs) is the + // fingerprint of the T212 pagination bug — even fully-lost positions + // usually have a few pence in trailing fractional sells. + if tr.totalSellProceeds == 0 && tr.totalDividends == 0 { + candidates = append(candidates, ticker) + } + } + s.returnsMu.RUnlock() + + if len(candidates) == 0 { + log.Printf("reconcile: no closed-position candidates need filling") + return + } + log.Printf("reconcile: %d closed-position candidate(s) may have missing sells; fetching per-ticker", len(candidates)) + + cached := loadOrdersCache(s.ordersCachePath) + knownKeys := make(map[string]bool, len(cached)) + for _, ci := range cached { + knownKeys[orderKey(ci)] = true + } + + var newOrders []trading212.OrderHistoryItem + for i, ticker := range candidates { + if i > 0 { + time.Sleep(11 * time.Second) // 6 req/60s rate limit + } + items, err := s.client.GetOrderHistoryByTicker(ticker) + if err != nil { + log.Printf("reconcile: %s fetch failed: %v", ticker, err) + continue + } + added := 0 + for _, item := range items { + // Skip cancelled orders (the no-fill shape T212 returns). + if item.Fill.WalletImpact.Currency == "" { + continue + } + k := orderKey(item) + if knownKeys[k] { + continue + } + newOrders = append(newOrders, item) + knownKeys[k] = true + added++ + } + log.Printf("reconcile: %s found %d previously-missing order(s)", ticker, added) + } + + if len(newOrders) == 0 { + log.Printf("reconcile: 0 new orders across %d candidate(s)", len(candidates)) + return + } + + merged := append(newOrders, cached...) + saveOrdersCache(s.ordersCachePath, merged) + mirrorOrders(s.backupDir, merged) + + dividends := loadDividendsCache(s.dividendsCachePath) + returns := processIntoReturns(merged, dividends) + s.returnsMu.Lock() + s.returns = returns + s.returnsMu.Unlock() + log.Printf("reconcile: merged %d previously-missing order(s) into cache and refreshed returns map", len(newOrders)) +} + // cachedReturns returns the current cached returns map. func (s *Service) cachedReturns() map[string]tickerReturns { s.returnsMu.RLock() @@ -580,6 +743,9 @@ func (s *Service) refreshSummary() { TotalPerformancePct: totalPerfPct, AnyProfitable: anyProfitable, LastUpdated: time.Now(), + OrdersFetchedAt: cacheMTime(s.ordersCachePath), + DividendsFetchedAt: cacheMTime(s.dividendsCachePath), + BackupConfigured: s.backupDir != "", } s.summaryMu.Lock() @@ -587,6 +753,20 @@ func (s *Service) refreshSummary() { s.summaryMu.Unlock() } +// cacheMTime returns the file modification time of a cache path, or the +// zero time if the file is missing/unreadable. Used to surface +// "last successful fetch" in the dashboard footer. +func cacheMTime(path string) time.Time { + if path == "" { + return time.Time{} + } + info, err := os.Stat(path) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + func computeReturn(tr tickerReturns) (ret, retPct, invested float64) { ret = tr.totalSellProceeds + tr.totalDividends invested = tr.totalBuyCost diff --git a/internal/portfolio/types.go b/internal/portfolio/types.go index f001206..4351855 100644 --- a/internal/portfolio/types.go +++ b/internal/portfolio/types.go @@ -52,6 +52,9 @@ type Summary struct { TotalPerformancePct float64 LastUpdated time.Time AnyProfitable bool - ClosedPositions []Position // fully sold positions from order history - Error string // non-empty if last fetch failed + ClosedPositions []Position // fully sold positions from order history + OrdersFetchedAt time.Time // when the orders cache was last successfully fetched + DividendsFetchedAt time.Time // when the dividends cache was last successfully fetched + BackupConfigured bool // true if backup_dir is set (shows backup status in footer) + Error string // non-empty if last fetch failed } diff --git a/internal/trading212/client.go b/internal/trading212/client.go index e77c031..d85128d 100644 --- a/internal/trading212/client.go +++ b/internal/trading212/client.go @@ -113,6 +113,29 @@ func (c *Client) GetOrderHistory() ([]OrderHistoryItem, error) { return all, nil } +// GetOrderHistoryByTicker fetches all order history pages for a single ticker. +// Used as a workaround for a Trading212 API bug where the unfiltered +// /equity/history/orders pagination silently drops orders in certain time +// windows; the ?ticker=X filter returns the missing orders correctly. +func (c *Client) GetOrderHistoryByTicker(ticker string) ([]OrderHistoryItem, error) { + var all []OrderHistoryItem + path := fmt.Sprintf("/equity/history/orders?ticker=%s&limit=50", ticker) + for { + var page OrderHistoryResponse + if err := c.getWithRetry(path, &page); err != nil { + return nil, fmt.Errorf("fetching order history for %s: %w", ticker, err) + } + all = append(all, page.Items...) + next := nextPage(page.NextPagePath) + if next == "" { + break + } + path = next + time.Sleep(11 * time.Second) + } + return all, nil +} + // GetDividendHistoryPage fetches a single page of dividend history. // Pass "" for the first page, or a nextPagePath value for subsequent pages. func (c *Client) GetDividendHistoryPage(path string) ([]DividendHistoryItem, string, error) { diff --git a/internal/web/handler.go b/internal/web/handler.go index 16ab21e..fe00fe6 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -431,6 +431,9 @@ func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) { TotalReturn float64 TotalProfit float64 TotalPerformancePct float64 + OrdersFetchedAt time.Time + DividendsFetchedAt time.Time + BackupConfigured bool }{ Positions: positions, Sort: sortBy, @@ -440,6 +443,9 @@ func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) { TotalReturn: totalReturn, TotalProfit: totalReturn - totalInvested, TotalPerformancePct: totalPerfPct, + OrdersFetchedAt: summary.OrdersFetchedAt, + DividendsFetchedAt: summary.DividendsFetchedAt, + BackupConfigured: summary.BackupConfigured, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/web/templates/history.html b/web/templates/history.html index 189694b..f24b2b7 100644 --- a/web/templates/history.html +++ b/web/templates/history.html @@ -97,6 +97,6 @@