Compare commits
No commits in common. "5ee2151a4737dffc420616d028cd10b2c36f1600" and "3ae53f15b000aa48eb7aa3336b235706857d53b4" have entirely different histories.
5ee2151a47
...
3ae53f15b0
10 changed files with 120 additions and 2067 deletions
24
TODO.md
24
TODO.md
|
|
@ -52,30 +52,6 @@ previously selected one (like `cd -` in bash or `Ctrl+^` in vim). Store
|
||||||
key swaps current and last. Works on any tab — particularly useful for
|
key swaps current and last. Works on any tab — particularly useful for
|
||||||
eyeball-comparing performance/risk data between two symbols.
|
eyeball-comparing performance/risk data between two symbols.
|
||||||
|
|
||||||
## Fix `enrich` command for international funds
|
|
||||||
|
|
||||||
`deriveMetadata` in `src/commands/enrich.zig` misclassifies international ETFs:
|
|
||||||
|
|
||||||
1. **`geo`** uses Alpha Vantage's `Country` field, which is the *fund issuer's*
|
|
||||||
domicile (USA for all US-listed ETFs), not the fund's investment geography.
|
|
||||||
Every US-domiciled international fund gets `geo::US`.
|
|
||||||
|
|
||||||
2. **`asset_class`** short-circuits to `"ETF"` when `asset_type == "ETF"`, or
|
|
||||||
falls through to a US-market-cap heuristic that always produces
|
|
||||||
`"US Large Cap"` / `"US Mid Cap"` / `"US Small Cap"`.
|
|
||||||
|
|
||||||
Known misclassified tickers (all came back as `geo::US, asset_class::US Large Cap`):
|
|
||||||
- **FRDM** — Freedom 100 Emerging Markets ETF → should be `geo::Emerging Markets, asset_class::Emerging Markets`
|
|
||||||
- **HFXI** — NYLI FTSE International Equity Currency Neutral ETF → should be `geo::International Developed, asset_class::International Developed`
|
|
||||||
- **IDMO** — Invesco S&P International Developed Momentum ETF → should be `geo::International Developed, asset_class::International Developed`
|
|
||||||
- **IVLU** — iShares MSCI International Developed Value Factor ETF → should be `geo::International Developed, asset_class::International Developed`
|
|
||||||
|
|
||||||
The Alpha Vantage OVERVIEW endpoint doesn't provide fund geography data.
|
|
||||||
Options: use the ETF_PROFILE holdings/country data to infer geography, parse
|
|
||||||
the fund name for keywords ("International", "Emerging", "ex-US"), or accept
|
|
||||||
that `enrich` is a scaffold and emit a `# TODO` comment for ETFs instead of
|
|
||||||
silently misclassifying.
|
|
||||||
|
|
||||||
## Market-aware cache TTL for daily candles
|
## Market-aware cache TTL for daily candles
|
||||||
|
|
||||||
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
|
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
|
||||||
|
|
|
||||||
13
metadata.srf
13
metadata.srf
|
|
@ -110,16 +110,3 @@ symbol::ORC42,asset_class::Bonds,geo::US,pct:num:8.33
|
||||||
symbol::VFORX,geo::US,asset_class::US Large Cap
|
symbol::VFORX,geo::US,asset_class::US Large Cap
|
||||||
|
|
||||||
symbol::VTTHX,geo::US,asset_class::US Large Cap
|
symbol::VTTHX,geo::US,asset_class::US Large Cap
|
||||||
|
|
||||||
# ── International ETFs ───────────────────────────────────────
|
|
||||||
# Freedom 100 Emerging Markets ETF
|
|
||||||
symbol::FRDM,sector::Diversified,geo::Emerging Markets,asset_class::Emerging Markets
|
|
||||||
|
|
||||||
# NYLI FTSE International Equity Currency Neutral ETF (50% hedged)
|
|
||||||
symbol::HFXI,sector::Diversified,geo::International Developed,asset_class::International Developed
|
|
||||||
|
|
||||||
# Invesco S&P International Developed Momentum ETF
|
|
||||||
symbol::IDMO,sector::Diversified,geo::International Developed,asset_class::International Developed
|
|
||||||
|
|
||||||
# iShares MSCI International Developed Value Factor ETF
|
|
||||||
symbol::IVLU,sector::Diversified,geo::International Developed,asset_class::International Developed
|
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ pub const TaxType = enum {
|
||||||
pub const AccountTaxEntry = struct {
|
pub const AccountTaxEntry = struct {
|
||||||
account: []const u8,
|
account: []const u8,
|
||||||
tax_type: TaxType,
|
tax_type: TaxType,
|
||||||
institution: ?[]const u8 = null,
|
|
||||||
account_number: ?[]const u8 = null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parsed account metadata.
|
/// Parsed account metadata.
|
||||||
|
|
@ -50,8 +48,6 @@ pub const AccountMap = struct {
|
||||||
pub fn deinit(self: *AccountMap) void {
|
pub fn deinit(self: *AccountMap) void {
|
||||||
for (self.entries) |e| {
|
for (self.entries) |e| {
|
||||||
self.allocator.free(e.account);
|
self.allocator.free(e.account);
|
||||||
if (e.institution) |s| self.allocator.free(s);
|
|
||||||
if (e.account_number) |s| self.allocator.free(s);
|
|
||||||
}
|
}
|
||||||
self.allocator.free(self.entries);
|
self.allocator.free(self.entries);
|
||||||
}
|
}
|
||||||
|
|
@ -65,42 +61,15 @@ pub const AccountMap = struct {
|
||||||
}
|
}
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the portfolio account name for a given institution + account number.
|
|
||||||
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
|
|
||||||
for (self.entries) |e| {
|
|
||||||
if (e.institution) |inst| {
|
|
||||||
if (e.account_number) |num| {
|
|
||||||
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
|
|
||||||
return e.account;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return all entries matching a given institution.
|
|
||||||
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
|
|
||||||
var count: usize = 0;
|
|
||||||
for (self.entries) |e| {
|
|
||||||
if (e.institution) |inst| {
|
|
||||||
if (std.mem.eql(u8, inst, institution)) count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (count == 0) return &.{};
|
|
||||||
return self.entries;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parse an accounts.srf file into an AccountMap.
|
/// Parse an accounts.srf file into an AccountMap.
|
||||||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
|
/// Each record has: account::<NAME>,tax_type::<TYPE>
|
||||||
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
|
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
|
||||||
var entries = std.ArrayList(AccountTaxEntry).empty;
|
var entries = std.ArrayList(AccountTaxEntry).empty;
|
||||||
errdefer {
|
errdefer {
|
||||||
for (entries.items) |e| {
|
for (entries.items) |e| {
|
||||||
allocator.free(e.account);
|
allocator.free(e.account);
|
||||||
if (e.institution) |s| allocator.free(s);
|
|
||||||
if (e.account_number) |s| allocator.free(s);
|
|
||||||
}
|
}
|
||||||
entries.deinit(allocator);
|
entries.deinit(allocator);
|
||||||
}
|
}
|
||||||
|
|
@ -114,8 +83,6 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
||||||
try entries.append(allocator, .{
|
try entries.append(allocator, .{
|
||||||
.account = try allocator.dupe(u8, entry.account),
|
.account = try allocator.dupe(u8, entry.account),
|
||||||
.tax_type = entry.tax_type,
|
.tax_type = entry.tax_type,
|
||||||
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
|
|
||||||
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
|
||||||
defer pf_data.deinit(allocator);
|
defer pf_data.deinit(allocator);
|
||||||
|
|
||||||
// Load classification metadata
|
// Load classification metadata
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
|
||||||
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
|
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
|
||||||
defer allocator.free(meta_path);
|
defer allocator.free(meta_path);
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
11
src/main.zig
11
src/main.zig
|
|
@ -20,7 +20,6 @@ const usage =
|
||||||
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
|
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
|
||||||
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
||||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||||
\\ audit [opts] Reconcile portfolio against brokerage export
|
|
||||||
\\ cache stats Show cache statistics
|
\\ cache stats Show cache statistics
|
||||||
\\ cache clear Clear all cached data
|
\\ cache clear Clear all cached data
|
||||||
\\
|
\\
|
||||||
|
|
@ -43,12 +42,6 @@ const usage =
|
||||||
\\ -w, --watchlist <FILE> Watchlist file
|
\\ -w, --watchlist <FILE> Watchlist file
|
||||||
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
||||||
\\
|
\\
|
||||||
\\Audit command options:
|
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
|
||||||
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
|
|
||||||
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
|
|
||||||
\\
|
|
||||||
\\Analysis command:
|
\\Analysis command:
|
||||||
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
||||||
\\ from the same directory as the portfolio file.
|
\\ from the same directory as the portfolio file.
|
||||||
|
|
@ -115,7 +108,6 @@ pub fn main() !u8 {
|
||||||
if (args.len >= 3 and
|
if (args.len >= 3 and
|
||||||
!std.mem.eql(u8, command, "cache") and
|
!std.mem.eql(u8, command, "cache") and
|
||||||
!std.mem.eql(u8, command, "enrich") and
|
!std.mem.eql(u8, command, "enrich") and
|
||||||
!std.mem.eql(u8, command, "audit") and
|
|
||||||
!std.mem.eql(u8, command, "analysis") and
|
!std.mem.eql(u8, command, "analysis") and
|
||||||
!std.mem.eql(u8, command, "portfolio"))
|
!std.mem.eql(u8, command, "portfolio"))
|
||||||
{
|
{
|
||||||
|
|
@ -222,8 +214,6 @@ pub fn main() !u8 {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.enrich.run(allocator, &svc, args[2], out);
|
try commands.enrich.run(allocator, &svc, args[2], out);
|
||||||
} else if (std.mem.eql(u8, command, "audit")) {
|
|
||||||
try commands.audit.run(allocator, &svc, args[2..], color, out);
|
|
||||||
} else if (std.mem.eql(u8, command, "analysis")) {
|
} else if (std.mem.eql(u8, command, "analysis")) {
|
||||||
// File path is first non-flag arg (default: portfolio.srf)
|
// File path is first non-flag arg (default: portfolio.srf)
|
||||||
var analysis_file: []const u8 = "portfolio.srf";
|
var analysis_file: []const u8 = "portfolio.srf";
|
||||||
|
|
@ -258,7 +248,6 @@ const commands = struct {
|
||||||
const lookup = @import("commands/lookup.zig");
|
const lookup = @import("commands/lookup.zig");
|
||||||
const cache = @import("commands/cache.zig");
|
const cache = @import("commands/cache.zig");
|
||||||
const analysis = @import("commands/analysis.zig");
|
const analysis = @import("commands/analysis.zig");
|
||||||
const audit = @import("commands/audit.zig");
|
|
||||||
const enrich = @import("commands/enrich.zig");
|
const enrich = @import("commands/enrich.zig");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,6 @@ pub const Lot = struct {
|
||||||
/// Aggregated position for a single symbol across multiple lots.
|
/// Aggregated position for a single symbol across multiple lots.
|
||||||
pub const Position = struct {
|
pub const Position = struct {
|
||||||
symbol: []const u8,
|
symbol: []const u8,
|
||||||
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
|
|
||||||
/// Same as `symbol` when no ticker alias is set.
|
|
||||||
lot_symbol: []const u8 = "",
|
|
||||||
/// Total open shares
|
/// Total open shares
|
||||||
shares: f64,
|
shares: f64,
|
||||||
/// Weighted average cost basis per share (open lots only)
|
/// Weighted average cost basis per share (open lots only)
|
||||||
|
|
@ -255,7 +252,6 @@ pub const Portfolio = struct {
|
||||||
if (!entry.found_existing) {
|
if (!entry.found_existing) {
|
||||||
entry.value_ptr.* = .{
|
entry.value_ptr.* = .{
|
||||||
.symbol = sym,
|
.symbol = sym,
|
||||||
.lot_symbol = lot.symbol,
|
|
||||||
.shares = 0,
|
.shares = 0,
|
||||||
.avg_cost = 0,
|
.avg_cost = 0,
|
||||||
.total_cost = 0,
|
.total_cost = 0,
|
||||||
|
|
@ -306,78 +302,6 @@ pub const Portfolio = struct {
|
||||||
return result.toOwnedSlice(allocator);
|
return result.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregate stock/ETF lots into positions for a single account.
|
|
||||||
/// Same logic as positions() but filtered to lots matching `account_name`.
|
|
||||||
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
|
|
||||||
pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
|
|
||||||
var map = std.StringHashMap(Position).init(allocator);
|
|
||||||
defer map.deinit();
|
|
||||||
|
|
||||||
for (self.lots) |lot| {
|
|
||||||
if (lot.security_type != .stock) continue;
|
|
||||||
const lot_acct = lot.account orelse continue;
|
|
||||||
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
|
|
||||||
|
|
||||||
const sym = lot.priceSymbol();
|
|
||||||
const entry = try map.getOrPut(sym);
|
|
||||||
if (!entry.found_existing) {
|
|
||||||
entry.value_ptr.* = .{
|
|
||||||
.symbol = sym,
|
|
||||||
.lot_symbol = lot.symbol,
|
|
||||||
.shares = 0,
|
|
||||||
.avg_cost = 0,
|
|
||||||
.total_cost = 0,
|
|
||||||
.open_lots = 0,
|
|
||||||
.closed_lots = 0,
|
|
||||||
.realized_gain_loss = 0,
|
|
||||||
.account = lot_acct,
|
|
||||||
.note = lot.note,
|
|
||||||
.price_ratio = lot.price_ratio,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
|
|
||||||
entry.value_ptr.price_ratio = lot.price_ratio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lot.isOpen()) {
|
|
||||||
entry.value_ptr.shares += lot.shares;
|
|
||||||
entry.value_ptr.total_cost += lot.costBasis();
|
|
||||||
entry.value_ptr.open_lots += 1;
|
|
||||||
} else {
|
|
||||||
entry.value_ptr.closed_lots += 1;
|
|
||||||
entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var iter = map.valueIterator();
|
|
||||||
while (iter.next()) |pos| {
|
|
||||||
if (pos.shares > 0) {
|
|
||||||
pos.avg_cost = pos.total_cost / pos.shares;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = std.ArrayList(Position).empty;
|
|
||||||
errdefer result.deinit(allocator);
|
|
||||||
|
|
||||||
var viter = map.valueIterator();
|
|
||||||
while (viter.next()) |pos| {
|
|
||||||
if (pos.open_lots == 0) continue;
|
|
||||||
try result.append(allocator, pos.*);
|
|
||||||
}
|
|
||||||
return result.toOwnedSlice(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total cash for a single account.
|
|
||||||
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
|
|
||||||
var total: f64 = 0;
|
|
||||||
for (self.lots) |lot| {
|
|
||||||
if (lot.security_type != .cash) continue;
|
|
||||||
const lot_acct = lot.account orelse continue;
|
|
||||||
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total cost basis of all open stock lots.
|
/// Total cost basis of all open stock lots.
|
||||||
pub fn totalCostBasis(self: Portfolio) f64 {
|
pub fn totalCostBasis(self: Portfolio) f64 {
|
||||||
var total: f64 = 0;
|
var total: f64 = 0;
|
||||||
|
|
@ -640,34 +564,3 @@ test "positions propagates price_ratio from lot" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "positionsForAccount excludes closed-only symbols" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
|
|
||||||
var lots = [_]Lot{
|
|
||||||
// Open lot in account A
|
|
||||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
|
||||||
// Closed lot in account A (was sold)
|
|
||||||
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
|
|
||||||
// Open lot for same symbol in a different account
|
|
||||||
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
|
|
||||||
};
|
|
||||||
|
|
||||||
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
|
||||||
|
|
||||||
// Account A: should only see AAPL (XLV is fully closed there)
|
|
||||||
const pos_a = try portfolio.positionsForAccount(allocator, "Acct A");
|
|
||||||
defer allocator.free(pos_a);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
|
|
||||||
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
|
|
||||||
|
|
||||||
// Account B: should see XLV with 50 shares
|
|
||||||
const pos_b = try portfolio.positionsForAccount(allocator, "Acct B");
|
|
||||||
defer allocator.free(pos_b);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
|
|
||||||
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
178
src/tui.zig
178
src/tui.zig
|
|
@ -95,7 +95,6 @@ pub const InputMode = enum {
|
||||||
symbol_input,
|
symbol_input,
|
||||||
help,
|
help,
|
||||||
account_picker,
|
account_picker,
|
||||||
account_search,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const StyledLine = struct {
|
pub const StyledLine = struct {
|
||||||
|
|
@ -310,15 +309,8 @@ pub const App = struct {
|
||||||
|
|
||||||
// Account filter state
|
// Account filter state
|
||||||
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
|
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
|
||||||
filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount)
|
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio)
|
||||||
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (ordered by accounts.srf)
|
|
||||||
account_numbers: std.ArrayList(?[]const u8) = .empty, // account_number from accounts.srf (parallel to account_list)
|
|
||||||
account_shortcut_keys: std.ArrayList(u8) = .empty, // auto-assigned shortcut key per account (parallel to account_list)
|
|
||||||
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
|
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
|
||||||
account_search_buf: [64]u8 = undefined,
|
|
||||||
account_search_len: usize = 0,
|
|
||||||
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
|
|
||||||
account_search_cursor: usize = 0, // cursor within search_matches
|
|
||||||
|
|
||||||
// Options navigation (inline expand/collapse like portfolio)
|
// Options navigation (inline expand/collapse like portfolio)
|
||||||
options_cursor: usize = 0, // selected row in flattened options view
|
options_cursor: usize = 0, // selected row in flattened options view
|
||||||
|
|
@ -395,9 +387,6 @@ pub const App = struct {
|
||||||
if (self.mode == .account_picker) {
|
if (self.mode == .account_picker) {
|
||||||
return self.handleAccountPickerKey(ctx, key);
|
return self.handleAccountPickerKey(ctx, key);
|
||||||
}
|
}
|
||||||
if (self.mode == .account_search) {
|
|
||||||
return self.handleAccountSearchKey(ctx, key);
|
|
||||||
}
|
|
||||||
if (self.mode == .help) {
|
if (self.mode == .help) {
|
||||||
self.mode = .normal;
|
self.mode = .normal;
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
|
|
@ -630,44 +619,19 @@ pub const App = struct {
|
||||||
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
||||||
const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
|
const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
|
||||||
|
|
||||||
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
|
// Cancel: return to normal mode without changing the filter
|
||||||
|
if (key.codepoint == vaxis.Key.escape) {
|
||||||
self.mode = .normal;
|
self.mode = .normal;
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirm: apply the selected account filter
|
||||||
if (key.codepoint == vaxis.Key.enter) {
|
if (key.codepoint == vaxis.Key.enter) {
|
||||||
self.applyAccountPickerSelection();
|
self.applyAccountPickerSelection();
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// '/' enters search mode
|
// Use the keymap for navigation actions
|
||||||
if (key.matches('/', .{})) {
|
|
||||||
self.mode = .account_search;
|
|
||||||
self.account_search_len = 0;
|
|
||||||
self.updateAccountSearchMatches();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'A' selects "All accounts" instantly
|
|
||||||
if (key.matches('A', .{})) {
|
|
||||||
self.account_picker_cursor = 0;
|
|
||||||
self.applyAccountPickerSelection();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check shortcut keys for instant selection
|
|
||||||
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
|
|
||||||
const ch: u8 = @intCast(key.codepoint);
|
|
||||||
for (self.account_shortcut_keys.items, 0..) |shortcut, i| {
|
|
||||||
if (shortcut == ch) {
|
|
||||||
self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
|
|
||||||
self.applyAccountPickerSelection();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation via keymap
|
|
||||||
const action = self.keymap.matchAction(key) orelse return;
|
const action = self.keymap.matchAction(key) orelse return;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
.select_next => {
|
.select_next => {
|
||||||
|
|
@ -693,111 +657,6 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles keypresses in account_search mode (/ search within picker).
|
|
||||||
fn handleAccountSearchKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
|
||||||
// Escape: cancel search, return to picker
|
|
||||||
if (key.codepoint == vaxis.Key.escape) {
|
|
||||||
self.mode = .account_picker;
|
|
||||||
self.account_search_len = 0;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter: select the first match (or current search cursor)
|
|
||||||
if (key.codepoint == vaxis.Key.enter) {
|
|
||||||
if (self.account_search_matches.items.len > 0) {
|
|
||||||
const match_idx = self.account_search_matches.items[self.account_search_cursor];
|
|
||||||
self.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
|
|
||||||
}
|
|
||||||
self.account_search_len = 0;
|
|
||||||
self.applyAccountPickerSelection();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
|
|
||||||
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
|
|
||||||
if (self.account_search_matches.items.len > 0 and
|
|
||||||
self.account_search_cursor < self.account_search_matches.items.len - 1)
|
|
||||||
self.account_search_cursor += 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
|
|
||||||
if (self.account_search_cursor > 0)
|
|
||||||
self.account_search_cursor -= 1;
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace
|
|
||||||
if (key.codepoint == vaxis.Key.backspace) {
|
|
||||||
if (self.account_search_len > 0) {
|
|
||||||
self.account_search_len -= 1;
|
|
||||||
self.updateAccountSearchMatches();
|
|
||||||
}
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+U: clear search
|
|
||||||
if (key.matches('u', .{ .ctrl = true })) {
|
|
||||||
self.account_search_len = 0;
|
|
||||||
self.updateAccountSearchMatches();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printable ASCII
|
|
||||||
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.account_search_len < self.account_search_buf.len) {
|
|
||||||
self.account_search_buf[self.account_search_len] = @intCast(key.codepoint);
|
|
||||||
self.account_search_len += 1;
|
|
||||||
self.updateAccountSearchMatches();
|
|
||||||
return ctx.consumeAndRedraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update search match indices based on current search string.
|
|
||||||
fn updateAccountSearchMatches(self: *App) void {
|
|
||||||
self.account_search_matches.clearRetainingCapacity();
|
|
||||||
const query = self.account_search_buf[0..self.account_search_len];
|
|
||||||
if (query.len == 0) return;
|
|
||||||
|
|
||||||
var lower_query: [64]u8 = undefined;
|
|
||||||
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
|
|
||||||
const lq = lower_query[0..query.len];
|
|
||||||
|
|
||||||
for (self.account_list.items, 0..) |acct, i| {
|
|
||||||
if (containsLower(acct, lq)) {
|
|
||||||
self.account_search_matches.append(self.allocator, i) catch continue;
|
|
||||||
} else if (i < self.account_numbers.items.len) {
|
|
||||||
if (self.account_numbers.items[i]) |num| {
|
|
||||||
if (containsLower(num, lq)) {
|
|
||||||
self.account_search_matches.append(self.allocator, i) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self.account_search_cursor >= self.account_search_matches.items.len) {
|
|
||||||
self.account_search_cursor = if (self.account_search_matches.items.len > 0)
|
|
||||||
self.account_search_matches.items.len - 1
|
|
||||||
else
|
|
||||||
0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
|
|
||||||
if (needle_lower.len == 0) return true;
|
|
||||||
if (haystack.len < needle_lower.len) return false;
|
|
||||||
const end = haystack.len - needle_lower.len + 1;
|
|
||||||
for (0..end) |start| {
|
|
||||||
var matched = true;
|
|
||||||
for (0..needle_lower.len) |j| {
|
|
||||||
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
|
|
||||||
matched = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (matched) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply the current account picker selection and return to normal mode.
|
/// Apply the current account picker selection and return to normal mode.
|
||||||
fn applyAccountPickerSelection(self: *App) void {
|
fn applyAccountPickerSelection(self: *App) void {
|
||||||
if (self.account_picker_cursor == 0) {
|
if (self.account_picker_cursor == 0) {
|
||||||
|
|
@ -823,31 +682,12 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
|
|
||||||
pub fn ensureAccountMap(self: *App) void {
|
|
||||||
if (self.account_map != null) return;
|
|
||||||
const ppath = self.portfolio_path orelse return;
|
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
||||||
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch return;
|
|
||||||
defer self.allocator.free(acct_path);
|
|
||||||
|
|
||||||
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
|
||||||
defer self.allocator.free(acct_data);
|
|
||||||
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
|
|
||||||
} else |_| {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set or clear the account filter. Owns the string via allocator.
|
/// Set or clear the account filter. Owns the string via allocator.
|
||||||
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
|
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
|
||||||
|
// Free the old owned copy
|
||||||
if (self.account_filter) |old| self.allocator.free(old);
|
if (self.account_filter) |old| self.allocator.free(old);
|
||||||
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
|
||||||
self.filtered_positions = null;
|
|
||||||
|
|
||||||
if (name) |n| {
|
if (name) |n| {
|
||||||
self.account_filter = self.allocator.dupe(u8, n) catch null;
|
self.account_filter = self.allocator.dupe(u8, n) catch null;
|
||||||
if (self.portfolio) |pf| {
|
|
||||||
self.filtered_positions = pf.positionsForAccount(self.allocator, n) catch null;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.account_filter = null;
|
self.account_filter = null;
|
||||||
}
|
}
|
||||||
|
|
@ -1506,11 +1346,7 @@ pub const App = struct {
|
||||||
self.portfolio_rows.deinit(self.allocator);
|
self.portfolio_rows.deinit(self.allocator);
|
||||||
self.options_rows.deinit(self.allocator);
|
self.options_rows.deinit(self.allocator);
|
||||||
self.account_list.deinit(self.allocator);
|
self.account_list.deinit(self.allocator);
|
||||||
self.account_numbers.deinit(self.allocator);
|
|
||||||
self.account_shortcut_keys.deinit(self.allocator);
|
|
||||||
self.account_search_matches.deinit(self.allocator);
|
|
||||||
if (self.account_filter) |af| self.allocator.free(af);
|
if (self.account_filter) |af| self.allocator.free(af);
|
||||||
if (self.filtered_positions) |fp| self.allocator.free(fp);
|
|
||||||
if (self.watchlist_prices) |*wp| wp.deinit();
|
if (self.watchlist_prices) |*wp| wp.deinit();
|
||||||
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
|
||||||
if (self.classification_map) |*cm| cm.deinit();
|
if (self.classification_map) |*cm| cm.deinit();
|
||||||
|
|
@ -1612,7 +1448,7 @@ pub const App = struct {
|
||||||
|
|
||||||
if (self.mode == .help) {
|
if (self.mode == .help) {
|
||||||
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
|
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
|
||||||
} else if (self.mode == .account_picker or self.mode == .account_search) {
|
} else if (self.mode == .account_picker) {
|
||||||
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
|
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
|
||||||
} else {
|
} else {
|
||||||
switch (self.active_tab) {
|
switch (self.active_tab) {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ pub fn loadData(app: *App) void {
|
||||||
// Look for metadata.srf next to the portfolio file
|
// Look for metadata.srf next to the portfolio file
|
||||||
if (app.portfolio_path) |ppath| {
|
if (app.portfolio_path) |ppath| {
|
||||||
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||||
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||||||
defer app.allocator.free(meta_path);
|
defer app.allocator.free(meta_path);
|
||||||
|
|
||||||
|
|
@ -40,7 +40,23 @@ pub fn loadData(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load account tax type metadata file (optional)
|
// Load account tax type metadata file (optional)
|
||||||
app.ensureAccountMap();
|
if (app.account_map == null) {
|
||||||
|
if (app.portfolio_path) |ppath| {
|
||||||
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||||
|
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
|
||||||
|
loadDataFinish(app, pf, summary);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer app.allocator.free(acct_path);
|
||||||
|
|
||||||
|
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
||||||
|
defer app.allocator.free(acct_data);
|
||||||
|
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
|
||||||
|
} else |_| {
|
||||||
|
// accounts.srf is optional -- analysis works without it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadDataFinish(app, pf, summary);
|
loadDataFinish(app, pf, summary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,6 @@ pub fn loadPortfolioData(app: *App) void {
|
||||||
|
|
||||||
sortPortfolioAllocations(app);
|
sortPortfolioAllocations(app);
|
||||||
buildAccountList(app);
|
buildAccountList(app);
|
||||||
recomputeFilteredPositions(app);
|
|
||||||
rebuildPortfolioRows(app);
|
rebuildPortfolioRows(app);
|
||||||
|
|
||||||
const summary = pf_data.summary;
|
const summary = pf_data.summary;
|
||||||
|
|
@ -289,14 +288,10 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
|
|
||||||
// Count lots for this symbol (filtered by account when filter is active)
|
// Count lots for this symbol (filtered by account when filter is active)
|
||||||
var lcount: usize = 0;
|
var lcount: usize = 0;
|
||||||
if (findFilteredPosition(app, a.symbol)) |pos| {
|
if (app.portfolio) |pf| {
|
||||||
lcount = pos.open_lots + pos.closed_lots;
|
for (pf.lots) |lot| {
|
||||||
} else if (app.account_filter == null) {
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||||
if (app.portfolio) |pf| {
|
if (matchesAccountFilter(app, lot.account)) lcount += 1;
|
||||||
for (pf.lots) |lot| {
|
|
||||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
|
||||||
lcount += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -548,73 +543,33 @@ pub fn rebuildPortfolioRows(app: *App) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the ordered list of distinct account names from portfolio lots.
|
/// Build the sorted list of distinct account names from portfolio lots.
|
||||||
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
|
/// Called after portfolio data is loaded or reloaded.
|
||||||
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
|
|
||||||
pub fn buildAccountList(app: *App) void {
|
pub fn buildAccountList(app: *App) void {
|
||||||
app.account_list.clearRetainingCapacity();
|
app.account_list.clearRetainingCapacity();
|
||||||
app.account_numbers.clearRetainingCapacity();
|
|
||||||
app.account_shortcut_keys.clearRetainingCapacity();
|
|
||||||
|
|
||||||
const pf = app.portfolio orelse return;
|
const pf = app.portfolio orelse return;
|
||||||
|
|
||||||
// Collect distinct account names from portfolio lots
|
// Use a set to deduplicate
|
||||||
var seen = std.StringHashMap(void).init(app.allocator);
|
var seen = std.StringHashMap(void).init(app.allocator);
|
||||||
defer seen.deinit();
|
defer seen.deinit();
|
||||||
|
|
||||||
var lot_accounts = std.ArrayList([]const u8).empty;
|
|
||||||
defer lot_accounts.deinit(app.allocator);
|
|
||||||
|
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.account) |acct| {
|
if (lot.account) |acct| {
|
||||||
if (acct.len > 0 and !seen.contains(acct)) {
|
if (acct.len > 0 and !seen.contains(acct)) {
|
||||||
seen.put(acct, {}) catch continue;
|
seen.put(acct, {}) catch continue;
|
||||||
lot_accounts.append(app.allocator, acct) catch continue;
|
app.account_list.append(app.allocator, acct) catch continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.ensureAccountMap();
|
// Sort alphabetically
|
||||||
|
std.mem.sort([]const u8, app.account_list.items, {}, struct {
|
||||||
// Phase 1: add accounts in accounts.srf order (if available)
|
|
||||||
if (app.account_map) |am| {
|
|
||||||
for (am.entries) |entry| {
|
|
||||||
if (seen.contains(entry.account)) {
|
|
||||||
app.account_list.append(app.allocator, entry.account) catch continue;
|
|
||||||
app.account_numbers.append(app.allocator, entry.account_number) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: add accounts not in accounts.srf, sorted alphabetically
|
|
||||||
var extras = std.ArrayList([]const u8).empty;
|
|
||||||
defer extras.deinit(app.allocator);
|
|
||||||
|
|
||||||
for (lot_accounts.items) |acct| {
|
|
||||||
var found = false;
|
|
||||||
for (app.account_list.items) |existing| {
|
|
||||||
if (std.mem.eql(u8, acct, existing)) {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) extras.append(app.allocator, acct) catch continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
std.mem.sort([]const u8, extras.items, {}, struct {
|
|
||||||
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
|
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
|
||||||
return std.mem.lessThan(u8, a, b);
|
return std.mem.lessThan(u8, a, b);
|
||||||
}
|
}
|
||||||
}.lessThan);
|
}.lessThan);
|
||||||
|
|
||||||
for (extras.items) |acct| {
|
|
||||||
app.account_list.append(app.allocator, acct) catch continue;
|
|
||||||
app.account_numbers.append(app.allocator, null) catch continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys)
|
|
||||||
assignShortcutKeys(app);
|
|
||||||
|
|
||||||
// If the current filter no longer exists in the new list, clear it
|
// If the current filter no longer exists in the new list, clear it
|
||||||
if (app.account_filter) |af| {
|
if (app.account_filter) |af| {
|
||||||
var found = false;
|
var found = false;
|
||||||
|
|
@ -628,41 +583,6 @@ pub fn buildAccountList(app: *App) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz";
|
|
||||||
|
|
||||||
fn assignShortcutKeys(app: *App) void {
|
|
||||||
app.account_shortcut_keys.clearRetainingCapacity();
|
|
||||||
var key_idx: usize = 0;
|
|
||||||
for (0..app.account_list.items.len) |_| {
|
|
||||||
if (key_idx < shortcut_key_order.len) {
|
|
||||||
app.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
|
|
||||||
key_idx += 1;
|
|
||||||
} else {
|
|
||||||
app.account_shortcut_keys.append(app.allocator, 0) catch continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute filtered_positions when portfolio or account filter changes.
|
|
||||||
fn recomputeFilteredPositions(app: *App) void {
|
|
||||||
if (app.filtered_positions) |fp| app.allocator.free(fp);
|
|
||||||
app.filtered_positions = null;
|
|
||||||
const filter = app.account_filter orelse return;
|
|
||||||
const pf = app.portfolio orelse return;
|
|
||||||
app.filtered_positions = pf.positionsForAccount(app.allocator, filter) catch null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Look up a symbol in the pre-computed filtered positions.
|
|
||||||
/// Returns null if no filter is active or the symbol isn't in the filtered account.
|
|
||||||
fn findFilteredPosition(app: *const App, symbol: []const u8) ?zfin.Position {
|
|
||||||
const fps = app.filtered_positions orelse return null;
|
|
||||||
for (fps) |pos| {
|
|
||||||
if (std.mem.eql(u8, pos.symbol, symbol) or std.mem.eql(u8, pos.lot_symbol, symbol))
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a lot matches the active account filter.
|
/// Check if a lot matches the active account filter.
|
||||||
/// Returns true if no filter is active or the lot's account matches.
|
/// Returns true if no filter is active or the lot's account matches.
|
||||||
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
|
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
|
||||||
|
|
@ -672,11 +592,25 @@ fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an allocation matches the active account filter.
|
/// Check if an allocation matches the active account filter.
|
||||||
/// When filtered, checks against pre-computed filtered_positions.
|
/// Uses the allocation's account field (which is "Multiple" for mixed-account positions).
|
||||||
|
/// For "Multiple" accounts, we need to check if any lot with this symbol belongs to the filtered account.
|
||||||
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
|
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
|
||||||
if (app.account_filter == null) return true;
|
const filter = app.account_filter orelse return true;
|
||||||
if (app.filtered_positions == null) return false;
|
// Simple case: allocation has a single account
|
||||||
return findFilteredPosition(app, a.symbol) != null;
|
if (!std.mem.eql(u8, a.account, "Multiple")) {
|
||||||
|
return std.mem.eql(u8, a.account, filter);
|
||||||
|
}
|
||||||
|
// "Multiple" account: check if any stock lot for this symbol belongs to the filtered account
|
||||||
|
if (app.portfolio) |pf| {
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||||
|
if (lot.account) |la| {
|
||||||
|
if (std.mem.eql(u8, la, filter)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Account-filtered view of an allocation. When a position spans multiple accounts,
|
/// Account-filtered view of an allocation. When a position spans multiple accounts,
|
||||||
|
|
@ -690,24 +624,41 @@ const FilteredAlloc = struct {
|
||||||
|
|
||||||
/// Compute account-filtered values for an allocation.
|
/// Compute account-filtered values for an allocation.
|
||||||
/// For single-account positions (or no filter), returns the allocation's own values.
|
/// For single-account positions (or no filter), returns the allocation's own values.
|
||||||
/// For filtered views, uses pre-computed filtered_positions.
|
/// For "Multiple"-account positions with a filter, sums only the matching lots.
|
||||||
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
|
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
|
||||||
if (app.account_filter == null) return .{
|
const filter = app.account_filter orelse return .{
|
||||||
.shares = a.shares,
|
.shares = a.shares,
|
||||||
.cost_basis = a.cost_basis,
|
.cost_basis = a.cost_basis,
|
||||||
.market_value = a.market_value,
|
.market_value = a.market_value,
|
||||||
.unrealized_gain_loss = a.unrealized_gain_loss,
|
.unrealized_gain_loss = a.unrealized_gain_loss,
|
||||||
};
|
};
|
||||||
if (findFilteredPosition(app, a.symbol)) |pos| {
|
if (!std.mem.eql(u8, a.account, "Multiple")) return .{
|
||||||
const mv = pos.shares * a.current_price * pos.price_ratio;
|
.shares = a.shares,
|
||||||
return .{
|
.cost_basis = a.cost_basis,
|
||||||
.shares = pos.shares,
|
.market_value = a.market_value,
|
||||||
.cost_basis = pos.total_cost,
|
.unrealized_gain_loss = a.unrealized_gain_loss,
|
||||||
.market_value = mv,
|
};
|
||||||
.unrealized_gain_loss = mv - pos.total_cost,
|
// Sum values from only the lots matching the filter
|
||||||
};
|
var shares: f64 = 0;
|
||||||
|
var cost: f64 = 0;
|
||||||
|
if (app.portfolio) |pf| {
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.security_type != .stock) continue;
|
||||||
|
if (!std.mem.eql(u8, lot.priceSymbol(), a.symbol)) continue;
|
||||||
|
if (!lot.isOpen()) continue;
|
||||||
|
const la = lot.account orelse "";
|
||||||
|
if (!std.mem.eql(u8, la, filter)) continue;
|
||||||
|
shares += lot.shares;
|
||||||
|
cost += lot.shares * lot.open_price;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 };
|
const mv = shares * a.current_price * a.price_ratio;
|
||||||
|
return .{
|
||||||
|
.shares = shares,
|
||||||
|
.cost_basis = cost,
|
||||||
|
.market_value = mv,
|
||||||
|
.unrealized_gain_loss = mv - cost,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Totals for the filtered account view (stocks + cash + CDs + options).
|
/// Totals for the filtered account view (stocks + cash + CDs + options).
|
||||||
|
|
@ -945,20 +896,38 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio) |pf| {
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||||
if (matchesAccountFilter(app, lot.account)) {
|
const ds = lot.open_date.format(&pos_date_buf);
|
||||||
const ds = lot.open_date.format(&pos_date_buf);
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
||||||
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
||||||
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
|
acct_col = lot.account orelse "";
|
||||||
acct_col = lot.account orelse "";
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (app.account_filter) |af| {
|
|
||||||
acct_col = af;
|
|
||||||
} else {
|
} else {
|
||||||
acct_col = a.account;
|
// Multi-lot: show account if all lots share the same one
|
||||||
|
if (app.portfolio) |pf| {
|
||||||
|
var common_acct: ?[]const u8 = null;
|
||||||
|
var mixed = false;
|
||||||
|
for (pf.lots) |lot| {
|
||||||
|
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
|
||||||
|
if (common_acct) |ca| {
|
||||||
|
const la = lot.account orelse "";
|
||||||
|
if (!std.mem.eql(u8, ca, la)) {
|
||||||
|
mixed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
common_acct = lot.account orelse "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mixed) {
|
||||||
|
acct_col = common_acct orelse "";
|
||||||
|
} else {
|
||||||
|
acct_col = "Multiple";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
|
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
|
||||||
|
|
@ -1285,7 +1254,6 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
|
|
||||||
sortPortfolioAllocations(app);
|
sortPortfolioAllocations(app);
|
||||||
buildAccountList(app);
|
buildAccountList(app);
|
||||||
recomputeFilteredPositions(app);
|
|
||||||
rebuildPortfolioRows(app);
|
rebuildPortfolioRows(app);
|
||||||
|
|
||||||
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
||||||
|
|
@ -1320,92 +1288,28 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell,
|
||||||
const th = app.theme;
|
const th = app.theme;
|
||||||
var lines: std.ArrayList(tui.StyledLine) = .empty;
|
var lines: std.ArrayList(tui.StyledLine) = .empty;
|
||||||
|
|
||||||
const is_searching = app.mode == .account_search;
|
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
|
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
// Build a set of search-highlighted indices for fast lookup
|
|
||||||
var search_highlight = std.AutoHashMap(usize, void).init(arena);
|
|
||||||
var search_cursor_idx: ?usize = null;
|
|
||||||
if (is_searching and app.account_search_matches.items.len > 0) {
|
|
||||||
for (app.account_search_matches.items, 0..) |match_idx, si| {
|
|
||||||
search_highlight.put(match_idx, {}) catch {};
|
|
||||||
if (si == app.account_search_cursor) search_cursor_idx = match_idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item 0 = "All accounts" (clears filter)
|
// Item 0 = "All accounts" (clears filter)
|
||||||
const total_items = app.account_list.items.len + 1;
|
const total_items = app.account_list.items.len + 1;
|
||||||
for (0..total_items) |i| {
|
for (0..total_items) |i| {
|
||||||
const is_selected = if (is_searching)
|
const is_selected = i == app.account_picker_cursor;
|
||||||
(if (search_cursor_idx) |sci| i == sci + 1 else false)
|
const marker: []const u8 = if (is_selected) " > " else " ";
|
||||||
else
|
const label: []const u8 = if (i == 0) "All accounts" else app.account_list.items[i - 1];
|
||||||
i == app.account_picker_cursor;
|
const text = try std.fmt.allocPrint(arena, "{s}{s}", .{ marker, label });
|
||||||
const marker: []const u8 = if (is_selected) " > " else " ";
|
const style = if (is_selected) th.selectStyle() else th.contentStyle();
|
||||||
|
try lines.append(arena, .{ .text = text, .style = style });
|
||||||
if (i == 0) {
|
|
||||||
const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
|
|
||||||
const style = if (is_selected) th.selectStyle() else th.contentStyle();
|
|
||||||
const dimmed = is_searching and app.account_search_len > 0;
|
|
||||||
try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
|
|
||||||
} else {
|
|
||||||
const acct_idx = i - 1;
|
|
||||||
const label = app.account_list.items[acct_idx];
|
|
||||||
const shortcut: u8 = if (acct_idx < app.account_shortcut_keys.items.len) app.account_shortcut_keys.items[acct_idx] else 0;
|
|
||||||
const acct_num: ?[]const u8 = if (acct_idx < app.account_numbers.items.len) app.account_numbers.items[acct_idx] else null;
|
|
||||||
|
|
||||||
const text = if (acct_num) |num|
|
|
||||||
(if (shortcut != 0)
|
|
||||||
try std.fmt.allocPrint(arena, "{s}{c}: {s} ({s})", .{ marker, shortcut, label, num })
|
|
||||||
else
|
|
||||||
try std.fmt.allocPrint(arena, "{s} {s} ({s})", .{ marker, label, num }))
|
|
||||||
else if (shortcut != 0)
|
|
||||||
try std.fmt.allocPrint(arena, "{s}{c}: {s}", .{ marker, shortcut, label })
|
|
||||||
else
|
|
||||||
try std.fmt.allocPrint(arena, "{s} {s}", .{ marker, label });
|
|
||||||
|
|
||||||
var style = if (is_selected) th.selectStyle() else th.contentStyle();
|
|
||||||
if (is_searching and app.account_search_len > 0) {
|
|
||||||
if (search_highlight.contains(acct_idx)) {
|
|
||||||
if (!is_selected) style = th.headerStyle();
|
|
||||||
} else {
|
|
||||||
style = th.mutedStyle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try lines.append(arena, .{ .text = text, .style = style });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search prompt at the bottom
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
if (is_searching) {
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
||||||
const query = app.account_search_buf[0..app.account_search_len];
|
|
||||||
const match_count = app.account_search_matches.items.len;
|
|
||||||
const prompt = if (query.len > 0)
|
|
||||||
try std.fmt.allocPrint(arena, " /{s} ({d} match{s})", .{
|
|
||||||
query,
|
|
||||||
match_count,
|
|
||||||
if (match_count != 1) @as([]const u8, "es") else "",
|
|
||||||
})
|
|
||||||
else
|
|
||||||
try std.fmt.allocPrint(arena, " /", .{});
|
|
||||||
try lines.append(arena, .{ .text = prompt, .style = th.headerStyle() });
|
|
||||||
} else {
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
||||||
try lines.append(arena, .{ .text = " /: search j/k: navigate Enter: select Esc: cancel", .style = th.mutedStyle() });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll so cursor is visible
|
// Scroll so cursor is visible
|
||||||
const effective_cursor = if (is_searching)
|
const cursor_line = app.account_picker_cursor + account_picker_header_lines;
|
||||||
(if (search_cursor_idx) |sci| sci + 1 else 0)
|
|
||||||
else
|
|
||||||
app.account_picker_cursor;
|
|
||||||
const cursor_line = effective_cursor + account_picker_header_lines;
|
|
||||||
var start: usize = 0;
|
var start: usize = 0;
|
||||||
if (cursor_line >= height) {
|
if (cursor_line >= height) {
|
||||||
start = cursor_line - height + 2;
|
start = cursor_line - height + 2; // keep one line of padding below
|
||||||
}
|
}
|
||||||
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue