feat: add portfolio
This commit is contained in:
11 files changed
+669
-38
No files matched your search
@@ -0,0 +1,307 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user