Files
2026-09-11 09:54:49 +08:00

308 lines
9.0 KiB
Go

// 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
}