308 lines
9.0 KiB
Go
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
|
|
}
|