// Package portfolio tracks a simulated paper-trading portfolio and executes // Claude's buy/sell recommendations against it. All arithmetic — position // sizing, leverage clamping, quantity, and profit/loss — is computed here in // Go from the prices fetched that run, never by Claude: Claude only supplies // a symbol, an action, and requested amount/leverage. Trades only ever // execute against the price fetched in the same run they're recommended in. package portfolio import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "gitea.konchin.com/service/stonks/internal/config" "gitea.konchin.com/service/stonks/internal/fetch" "gitea.konchin.com/service/stonks/internal/report" ) const ( defaultDir = "~/.local/state/stonks" defaultInitialBudgetUSD = 10000 defaultMaxLeverage = 3 sideLong = "long" sideShort = "short" ) // Position is one open, leveraged paper-trading position. type Position struct { Symbol string `json:"symbol"` Side string `json:"side"` // "long" or "short" Quantity float64 `json:"quantity"` EntryPrice float64 `json:"entry_price"` Margin float64 `json:"margin"` // USD committed, before leverage Leverage float64 `json:"leverage"` OpenedAt time.Time `json:"opened_at"` } // State is the persisted portfolio: cash on hand, open positions, and a // running log of closed trades. It is saved to portfolio.json after every // run so state survives across the systemd timer's invocations. type State struct { InitialBudgetUSD float64 `json:"initial_budget_usd"` CashUSD float64 `json:"cash_usd"` Positions map[string]Position `json:"positions"` // by symbol UpdatedAt time.Time `json:"updated_at"` } // Load reads portfolio.json from cfg.Dir, or returns a fresh State seeded // with cfg.InitialBudgetUSD (default $10,000) if it doesn't exist yet. func Load(cfg config.PortfolioConfig) (State, error) { dir, err := resolveDir(cfg.Dir) if err != nil { return State{}, err } data, err := os.ReadFile(filepath.Join(dir, "portfolio.json")) if err != nil { if os.IsNotExist(err) { return State{ InitialBudgetUSD: initialBudget(cfg), CashUSD: initialBudget(cfg), Positions: map[string]Position{}, }, nil } return State{}, fmt.Errorf("portfolio: read state: %w", err) } var s State if err := json.Unmarshal(data, &s); err != nil { return State{}, fmt.Errorf("portfolio: parse state: %w", err) } if s.Positions == nil { s.Positions = map[string]Position{} } return s, nil } // Save writes State to portfolio.json under cfg.Dir. func Save(cfg config.PortfolioConfig, s State) error { dir, err := resolveDir(cfg.Dir) if err != nil { return err } if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("portfolio: create %q: %w", dir, err) } data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("portfolio: marshal state: %w", err) } if err := os.WriteFile(filepath.Join(dir, "portfolio.json"), data, 0o644); err != nil { return fmt.Errorf("portfolio: write state: %w", err) } return nil } // Apply executes recs against State at the prices in quotes — the same // fetched data the recommendations were generated from — mutating s in // place and returning the resulting snapshot for reporting. Recommendations // for symbols with no matching quote, or with a non-positive amount, are // skipped rather than failing the run. func Apply(s *State, cfg config.PortfolioConfig, recs []report.Recommendation, quotes []fetch.Quote, now time.Time) report.PortfolioSnapshot { price := latestPriceBySymbol(quotes) maxLeverage := maxLeverage(cfg) var trades []report.TradeExecution for _, rec := range recs { trades = append(trades, applyOne(s, rec, price, maxLeverage, now)) } s.UpdatedAt = now return snapshot(*s, price, trades) } func applyOne(s *State, rec report.Recommendation, price map[string]float64, maxLeverage float64, now time.Time) report.TradeExecution { symbol := rec.Symbol action := strings.ToLower(rec.Action) exec := report.TradeExecution{Symbol: symbol, Action: action} p, ok := price[symbol] if !ok || p <= 0 { exec.Note = "skipped: no fetched price for this symbol this run" return exec } exec.Price = p if action != "buy" && action != "sell" { exec.Note = fmt.Sprintf("skipped: invalid action %q", rec.Action) return exec } leverage := rec.Leverage if leverage <= 0 { leverage = 1 } if leverage > maxLeverage { leverage = maxLeverage } pos, hasPosition := s.Positions[symbol] // A buy against an open short (or a sell against an open long) closes // that position instead of opening a new one in the same symbol. if hasPosition && pos.Side == sideLong && action == "sell" { return closePosition(s, pos, p, now, exec) } if hasPosition && pos.Side == sideShort && action == "buy" { return closePosition(s, pos, p, now, exec) } if hasPosition { exec.Note = fmt.Sprintf("skipped: already holding a %s position in %s; close it first", pos.Side, symbol) return exec } amount := rec.AmountUSD if amount > s.CashUSD { amount = s.CashUSD } if amount <= 0 { exec.Note = "skipped: no cash available or no amount requested" return exec } side := sideLong if action == "sell" { side = sideShort } quantity := (amount * leverage) / p s.CashUSD -= amount s.Positions[symbol] = Position{ Symbol: symbol, Side: side, Quantity: quantity, EntryPrice: p, Margin: amount, Leverage: leverage, OpenedAt: now, } exec.Executed = true exec.AmountUSD = amount exec.Leverage = leverage exec.Note = fmt.Sprintf("opened %s: %.6g units at %.4g (%.2fx leverage, $%.2f margin)", side, quantity, p, leverage, amount) return exec } // closePosition fully closes pos at price p, realizing profit/loss into // s.CashUSD, and fills in exec accordingly. func closePosition(s *State, pos Position, p float64, now time.Time, exec report.TradeExecution) report.TradeExecution { pnl := (p - pos.EntryPrice) * pos.Quantity if pos.Side == sideShort { pnl = (pos.EntryPrice - p) * pos.Quantity } s.CashUSD += pos.Margin + pnl delete(s.Positions, pos.Symbol) exec.Executed = true exec.AmountUSD = pos.Margin exec.Leverage = pos.Leverage exec.RealizedPnL = pnl exec.Note = fmt.Sprintf("closed %s position: %.6g units, entry %.4g -> exit %.4g, realized $%.2f", pos.Side, pos.Quantity, pos.EntryPrice, p, pnl) return exec } // Snapshot marks every open position in s to market against quotes, without // executing any trades — used to show Claude the portfolio's current state // before it makes this run's calls. func Snapshot(s State, quotes []fetch.Quote) report.PortfolioSnapshot { return snapshot(s, latestPriceBySymbol(quotes), nil) } // snapshot marks every open position to market using the latest fetched // prices (falling back to the position's entry price if this run didn't // fetch a fresh quote for it) and totals up equity/profit. func snapshot(s State, price map[string]float64, trades []report.TradeExecution) report.PortfolioSnapshot { snap := report.PortfolioSnapshot{ InitialBudgetUSD: s.InitialBudgetUSD, CashUSD: s.CashUSD, Trades: trades, } equity := s.CashUSD for _, pos := range s.Positions { current, ok := price[pos.Symbol] if !ok { current = pos.EntryPrice } unrealized := (current - pos.EntryPrice) * pos.Quantity if pos.Side == sideShort { unrealized = (pos.EntryPrice - current) * pos.Quantity } equity += pos.Margin + unrealized snap.Positions = append(snap.Positions, report.Position{ Symbol: pos.Symbol, Side: pos.Side, Quantity: pos.Quantity, EntryPrice: pos.EntryPrice, CurrentPrice: current, Margin: pos.Margin, Leverage: pos.Leverage, UnrealizedPnL: unrealized, }) } snap.EquityUSD = equity snap.TotalProfitUSD = equity - s.InitialBudgetUSD if s.InitialBudgetUSD != 0 { snap.TotalProfitPct = snap.TotalProfitUSD / s.InitialBudgetUSD * 100 } return snap } func latestPriceBySymbol(quotes []fetch.Quote) map[string]float64 { m := make(map[string]float64, len(quotes)) for _, q := range quotes { if q.Price > 0 { m[q.Symbol] = q.Price } } return m } func initialBudget(cfg config.PortfolioConfig) float64 { if cfg.InitialBudgetUSD > 0 { return cfg.InitialBudgetUSD } return defaultInitialBudgetUSD } // MaxLeverage returns cfg.MaxLeverage, or the default if unset — exported so // callers building the prompt can tell Claude the same cap Apply enforces. func MaxLeverage(cfg config.PortfolioConfig) float64 { return maxLeverage(cfg) } func maxLeverage(cfg config.PortfolioConfig) float64 { if cfg.MaxLeverage > 0 { return cfg.MaxLeverage } return defaultMaxLeverage } func resolveDir(dir string) (string, error) { if dir == "" { dir = defaultDir } if dir != "~" && !strings.HasPrefix(dir, "~/") { return dir, nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("portfolio: resolve home directory: %w", err) } if dir == "~" { return home, nil } return filepath.Join(home, dir[2:]), nil }