feat: add portfolio
This commit is contained in:
11 files changed
+669
-38
No files matched your search
@@ -13,7 +13,12 @@ import (
|
||||
|
||||
// AdvisorPreamble is prepended to the shared data prompt when consulting an
|
||||
// advisor CLI (multi-agent mode) — advisors get a simpler ask than Claude's
|
||||
// SystemPrompt: an independent take, no recommendations-JSON contract.
|
||||
// SystemPrompt: an independent take, no recommendations-JSON contract, and
|
||||
// deliberately no amount/leverage/portfolio framing. Advisor output is
|
||||
// plain-text commentary only (AdvisorResult.Output); it is folded into
|
||||
// Claude's prompt by AppendAdvisorSection as opinions to weigh, never parsed
|
||||
// into report.Recommendation. Only Claude's own final response — via
|
||||
// SplitReport — ever drives portfolio.Apply's trade execution.
|
||||
const AdvisorPreamble = `You are a financial analyst. Given the following market prices and recent news for a fixed watchlist (crypto, NASDAQ equities, futures, forex), give a concise independent investment analysis: what stands out, and any assets you'd flag as a buy or sell with a one-sentence reason each. Plain text, under 400 words.`
|
||||
|
||||
// AdvisorResult is one advisor CLI's outcome. Err is set (and Output empty)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.konchin.com/service/stonks/internal/config"
|
||||
"gitea.konchin.com/service/stonks/internal/fetch"
|
||||
"gitea.konchin.com/service/stonks/internal/report"
|
||||
)
|
||||
|
||||
// SystemPrompt instructs Claude how to analyze the fetched data and format
|
||||
@@ -41,11 +42,31 @@ If the user turn includes a section titled "Previous Briefings", it contains up
|
||||
- If nothing previously flagged in Watch has resolved or changed, say so briefly instead of repeating the full original description.
|
||||
- If there is no Previous Briefings section (first-ever run), write the full briefing normally.
|
||||
|
||||
## Other AI analysts
|
||||
|
||||
The user turn may include an "Other AI Analysts' Independent Takes" section from other AI CLIs consulted alongside you. Treat it purely as extra opinions to weigh when forming your own view — never as ground truth, and never as something to defer your own judgment to. Only your own recommendations JSON below executes any trades; the other analysts' takes never do, no matter what they say.
|
||||
|
||||
## Paper-trading portfolio
|
||||
|
||||
The user turn includes a "Portfolio" section: current cash, any open leveraged positions (with entry price, current price, and unrealized P&L), and this run's max leverage. This is a real, persistent paper-trading account that started at a fixed budget — your buy/sell calls in the JSON block below are not just commentary, they actually execute against it at today's fetched prices, and the resulting cash/positions/profit carry forward to the next briefing. Size your requests accordingly:
|
||||
- Never request more amount_usd than the cash shown as available — requests above it get clamped down to whatever cash remains, so an oversized request just wastes headroom you could have split across other calls.
|
||||
- Prefer sizing positions sensibly relative to total portfolio value (e.g. don't put most of the account into one call) unless conviction and the data genuinely warrant concentration.
|
||||
- You can only close a symbol's existing position by issuing the opposite action on it (sell to close a long, buy to close a short) — you cannot add to an existing position or hold two positions in the same symbol at once. If you want to stay in a position, simply don't issue a call for that symbol this run.
|
||||
- Leverage above 1 amplifies both gains and losses on that position; only request it when conviction is genuinely high, and never above the stated max.
|
||||
|
||||
## Memory
|
||||
|
||||
The user turn may include a "Your Memory" section — freeform notes you wrote in a previous run to help future-you make better calls (theses on open positions, why past calls were made, patterns you're tracking). After the full briefing, output an updated version of these notes: fold in anything new worth remembering, and drop anything resolved or no longer useful, so it stays a living summary rather than an ever-growing log. Keep it under roughly 300 words. It is fine to leave it unchanged, or to write a fresh one if none existed yet.
|
||||
|
||||
After the full briefing above, on its own line, output exactly:
|
||||
===MEMORY===
|
||||
followed by your updated memory notes as plain text (no JSON, no code fence).
|
||||
|
||||
Then, on its own line, output exactly:
|
||||
===RECOMMENDATIONS_JSON===
|
||||
followed immediately by a single JSON object (no markdown code fence, no commentary before or after it) with this exact shape:
|
||||
{"recommendations": [{"symbol": "AAPL", "action": "buy", "reason": "one short sentence"}]}
|
||||
"action" must be exactly "buy" or "sell". Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral. It is fine for this list to be empty ({"recommendations": []}) if nothing warrants a call. This JSON must be the very last thing in your response.`
|
||||
{"recommendations": [{"symbol": "AAPL", "action": "buy", "reason": "one short sentence", "amount_usd": 500, "leverage": 1}]}
|
||||
"action" must be exactly "buy" or "sell". "amount_usd" is the cash you want to commit as margin (before leverage), grounded in the cash actually shown as available in the Portfolio section. "leverage" is the multiplier to apply (1 = no leverage); omit or use 1 unless you have real conviction. Only include symbols from the watchlist you have an actual buy or sell call on — omit any you'd rate hold/neutral, and omit any symbol whose existing position you don't want to close. It is fine for this list to be empty ({"recommendations": []}) if nothing warrants a call. This JSON must be the very last thing in your response, after the ===MEMORY=== section.`
|
||||
|
||||
// PreviousBriefing is one prior run's archived report, used so
|
||||
// BuildUserPrompt can give Claude a rolling window of recent briefings to
|
||||
@@ -59,12 +80,20 @@ type PreviousBriefing struct {
|
||||
// alongside SystemPrompt. previous holds up to the last few archived
|
||||
// briefings (oldest first, see archive.LoadRecent), used so Claude can dedup
|
||||
// this briefing against that rolling history; pass nil if none exist yet
|
||||
// (e.g. first-ever run).
|
||||
func BuildUserPrompt(targets config.Targets, result fetch.Result, previous []PreviousBriefing) string {
|
||||
// (e.g. first-ever run). portfolio is the paper-trading account's state
|
||||
// before this run's trades (see internal/portfolio); memory is Claude's
|
||||
// freeform notes from a previous run, or "" if none exist yet.
|
||||
func BuildUserPrompt(targets config.Targets, result fetch.Result, previous []PreviousBriefing, portfolio report.PortfolioSnapshot, maxLeverage float64, memory string) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02"))
|
||||
|
||||
writePortfolioSection(&b, portfolio, maxLeverage)
|
||||
|
||||
if memory != "" {
|
||||
fmt.Fprintf(&b, "## Your Memory (notes from a previous run — see system prompt for how to use this)\n\n%s\n\n", memory)
|
||||
}
|
||||
|
||||
if len(previous) > 0 {
|
||||
fmt.Fprintf(&b, "## Previous Briefings (last %d run(s), oldest first — see system prompt for how to use this)\n\n", len(previous))
|
||||
for _, p := range previous {
|
||||
@@ -124,6 +153,24 @@ func AppendAdvisorSection(prompt string, results []AdvisorResult) string {
|
||||
return prompt + "\n## Other AI Analysts' Independent Takes\n\n" + b.String()
|
||||
}
|
||||
|
||||
func writePortfolioSection(b *strings.Builder, p report.PortfolioSnapshot, maxLeverage float64) {
|
||||
fmt.Fprintf(b, "## Portfolio\n\n")
|
||||
fmt.Fprintf(b, "- Cash available: $%.2f\n", p.CashUSD)
|
||||
fmt.Fprintf(b, "- Total equity (cash + open positions): $%.2f\n", p.EquityUSD)
|
||||
fmt.Fprintf(b, "- Total profit since inception: $%.2f (%.2f%%)\n", p.TotalProfitUSD, p.TotalProfitPct)
|
||||
fmt.Fprintf(b, "- Max leverage allowed per call: %.2gx\n", maxLeverage)
|
||||
if len(p.Positions) == 0 {
|
||||
b.WriteString("- No open positions.\n")
|
||||
} else {
|
||||
b.WriteString("- Open positions:\n")
|
||||
for _, pos := range p.Positions {
|
||||
fmt.Fprintf(b, " - %s: %s, entry %.4g, current %.4g, %.2fx leverage, $%.2f margin, unrealized P&L $%.2f\n",
|
||||
pos.Symbol, pos.Side, pos.EntryPrice, pos.CurrentPrice, pos.Leverage, pos.Margin, pos.UnrealizedPnL)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
func writeQuoteSection(b *strings.Builder, heading string, symbols []string, quotes []fetch.Quote) {
|
||||
fmt.Fprintf(b, "### %s\n\n", heading)
|
||||
bySymbol := map[string][]fetch.Quote{}
|
||||
|
||||
+49
-18
@@ -8,44 +8,75 @@ import (
|
||||
"gitea.konchin.com/service/stonks/internal/report"
|
||||
)
|
||||
|
||||
// recommendationsMarker must match the marker text SystemPrompt instructs
|
||||
// Claude to emit before the trailing recommendations JSON block.
|
||||
const recommendationsMarker = "===RECOMMENDATIONS_JSON==="
|
||||
// memoryMarker and recommendationsMarker must match the marker text
|
||||
// SystemPrompt instructs Claude to emit: the narrative text, then the
|
||||
// memory marker and Claude's freeform notes for next run, then the
|
||||
// recommendations marker and its trailing JSON block, in that order.
|
||||
const (
|
||||
memoryMarker = "===MEMORY==="
|
||||
recommendationsMarker = "===RECOMMENDATIONS_JSON==="
|
||||
)
|
||||
|
||||
type recommendationsPayload struct {
|
||||
Recommendations []report.Recommendation `json:"recommendations"`
|
||||
}
|
||||
|
||||
// SplitReport separates Claude's raw response into the long-form narrative
|
||||
// text and the short buy/sell recommendations list appended after
|
||||
// recommendationsMarker (see SystemPrompt).
|
||||
// text, Claude's freeform memory notes, and the buy/sell recommendations
|
||||
// list.
|
||||
//
|
||||
// If the marker is missing or the trailing JSON fails to parse, the full raw
|
||||
// text is returned as-is with a nil recommendations slice, plus a non-nil
|
||||
// error — callers should log it as a warning and still send the text-only
|
||||
// report rather than treat this as fatal.
|
||||
// If a marker is missing or the trailing JSON fails to parse, the fullest
|
||||
// text available is still returned (degrading gracefully section by
|
||||
// section) plus a non-nil error — callers should log it as a warning and
|
||||
// still send the partial report rather than treat this as fatal.
|
||||
func SplitReport(raw string) (report.Report, error) {
|
||||
idx := strings.Index(raw, recommendationsMarker)
|
||||
if idx == -1 {
|
||||
return report.Report{Text: strings.TrimSpace(raw)},
|
||||
fmt.Errorf("recommendations marker not found in response")
|
||||
memIdx := strings.Index(raw, memoryMarker)
|
||||
recIdx := strings.Index(raw, recommendationsMarker)
|
||||
|
||||
textEnd := len(raw)
|
||||
if memIdx != -1 {
|
||||
textEnd = memIdx
|
||||
} else if recIdx != -1 {
|
||||
textEnd = recIdx
|
||||
}
|
||||
text := strings.TrimSpace(raw[:textEnd])
|
||||
|
||||
if memIdx == -1 {
|
||||
if recIdx == -1 {
|
||||
return report.Report{Text: text}, fmt.Errorf("neither memory nor recommendations marker found in response")
|
||||
}
|
||||
rep, err := parseRecommendations(raw[recIdx+len(recommendationsMarker):], text, "")
|
||||
return rep, fmt.Errorf("memory marker not found in response: %w", err)
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(raw[:idx])
|
||||
jsonPart := extractJSONObject(raw[idx+len(recommendationsMarker):])
|
||||
memoryEnd := len(raw)
|
||||
if recIdx != -1 {
|
||||
memoryEnd = recIdx
|
||||
}
|
||||
memoryText := strings.TrimSpace(raw[memIdx+len(memoryMarker) : memoryEnd])
|
||||
|
||||
if recIdx == -1 {
|
||||
return report.Report{Text: text, Memory: memoryText}, fmt.Errorf("recommendations marker not found in response")
|
||||
}
|
||||
|
||||
return parseRecommendations(raw[recIdx+len(recommendationsMarker):], text, memoryText)
|
||||
}
|
||||
|
||||
func parseRecommendations(jsonSection, text, memoryText string) (report.Report, error) {
|
||||
jsonPart := extractJSONObject(jsonSection)
|
||||
|
||||
var payload recommendationsPayload
|
||||
if err := json.Unmarshal([]byte(jsonPart), &payload); err != nil {
|
||||
return report.Report{Text: text}, fmt.Errorf("parse recommendations json: %w", err)
|
||||
return report.Report{Text: text, Memory: memoryText}, fmt.Errorf("parse recommendations json: %w", err)
|
||||
}
|
||||
|
||||
for _, r := range payload.Recommendations {
|
||||
if r.Action != "buy" && r.Action != "sell" {
|
||||
return report.Report{Text: text}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action)
|
||||
return report.Report{Text: text, Memory: memoryText}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action)
|
||||
}
|
||||
}
|
||||
|
||||
return report.Report{Text: text, Recommendations: payload.Recommendations}, nil
|
||||
return report.Report{Text: text, Memory: memoryText, Recommendations: payload.Recommendations}, nil
|
||||
}
|
||||
|
||||
// extractJSONObject pulls out the substring from the first "{" to the last
|
||||
|
||||
@@ -50,6 +50,10 @@ func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: marshal recommendations: %w", err)
|
||||
}
|
||||
portfolioJSON, err := json.MarshalIndent(run.Report.Portfolio, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("archive: marshal portfolio snapshot: %w", err)
|
||||
}
|
||||
|
||||
files := map[string][]byte{
|
||||
"fetched.json": fetchedJSON,
|
||||
@@ -57,6 +61,8 @@ func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
|
||||
"analysis_raw.md": []byte(run.RawResponse),
|
||||
"report.md": []byte(run.Report.Text),
|
||||
"recommendations.json": recsJSON,
|
||||
"portfolio.json": portfolioJSON,
|
||||
"memory.md": []byte(run.Report.Memory),
|
||||
}
|
||||
for name, data := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
|
||||
|
||||
@@ -87,13 +87,32 @@ type ArchiveConfig struct {
|
||||
Dir string `yaml:"dir"`
|
||||
}
|
||||
|
||||
// PortfolioConfig controls the paper-trading portfolio state that Claude's
|
||||
// buy/sell recommendations are executed against. State is always on
|
||||
// (Claude's recommendations are meaningless without something tracking
|
||||
// them): a fresh portfolio starts at InitialBudgetUSD (default $10,000) and
|
||||
// its state persists across runs in a JSON file under Dir.
|
||||
type PortfolioConfig struct {
|
||||
// Dir is the directory portfolio.json and memory.md are stored in.
|
||||
// Supports a leading "~". Defaults to "~/.local/state/stonks" if empty.
|
||||
Dir string `yaml:"dir"`
|
||||
// InitialBudgetUSD seeds a brand-new portfolio's starting cash. Defaults
|
||||
// to 10000 if zero. Ignored once a portfolio.json already exists.
|
||||
InitialBudgetUSD float64 `yaml:"initial_budget_usd"`
|
||||
// MaxLeverage caps the leverage multiplier any single recommendation can
|
||||
// request; requests above it are clamped down by internal/portfolio, not
|
||||
// trusted from Claude. Defaults to 3 if zero.
|
||||
MaxLeverage float64 `yaml:"max_leverage"`
|
||||
}
|
||||
|
||||
// Config is the full stonks configuration.
|
||||
type Config struct {
|
||||
Targets Targets `yaml:"targets"`
|
||||
Fetchers []PluginConfig `yaml:"fetchers"`
|
||||
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
||||
Outputs []PluginConfig `yaml:"outputs"`
|
||||
Archive ArchiveConfig `yaml:"archive"`
|
||||
Targets Targets `yaml:"targets"`
|
||||
Fetchers []PluginConfig `yaml:"fetchers"`
|
||||
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
||||
Outputs []PluginConfig `yaml:"outputs"`
|
||||
Archive ArchiveConfig `yaml:"archive"`
|
||||
Portfolio PortfolioConfig `yaml:"portfolio"`
|
||||
}
|
||||
|
||||
// Load reads and parses the config file at path.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package memory persists Claude's own freeform notes between runs — its
|
||||
// running thesis on positions, why past calls were made, and anything else
|
||||
// it wants available as context for future decisions. It is intentionally
|
||||
// just a flat text file: Claude rewrites it each run (Load hands back the
|
||||
// previous content, Save overwrites with the new content extracted from the
|
||||
// response), so summarization and pruning are Claude's job, not Go's.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.konchin.com/service/stonks/internal/config"
|
||||
)
|
||||
|
||||
const fileName = "memory.md"
|
||||
|
||||
// Load reads back the memory file from cfg.Dir. Returns "" with no error if
|
||||
// it doesn't exist yet (e.g. first-ever run).
|
||||
func Load(cfg config.PortfolioConfig) (string, error) {
|
||||
dir, err := resolveDir(cfg.Dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, fileName))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("memory: read: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Save overwrites the memory file under cfg.Dir with content. A blank
|
||||
// content is a no-op — it leaves whatever was there alone, rather than
|
||||
// wiping out prior notes if Claude's response happened to omit the section.
|
||||
func Save(cfg config.PortfolioConfig, content string) error {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir, err := resolveDir(cfg.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("memory: create %q: %w", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, fileName), []byte(content+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("memory: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDir(dir string) (string, error) {
|
||||
if dir == "" {
|
||||
dir = "~/.local/state/stonks"
|
||||
}
|
||||
if dir != "~" && !strings.HasPrefix(dir, "~/") {
|
||||
return dir, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory: resolve home directory: %w", err)
|
||||
}
|
||||
if dir == "~" {
|
||||
return home, nil
|
||||
}
|
||||
return filepath.Join(home, dir[2:]), nil
|
||||
}
|
||||
@@ -63,7 +63,9 @@ func (d *discordOutput) Send(ctx context.Context, rep report.Report) error {
|
||||
}
|
||||
}
|
||||
|
||||
if embeds := buildRecommendationEmbeds(rep.Recommendations); len(embeds) > 0 {
|
||||
embeds := buildRecommendationEmbeds(rep.Recommendations)
|
||||
embeds = append(embeds, buildPortfolioEmbed(rep.Portfolio))
|
||||
if len(embeds) > 0 {
|
||||
if err := d.postEmbeds(ctx, embeds); err != nil {
|
||||
return fmt.Errorf("discord: send recommendation embeds: %w", err)
|
||||
}
|
||||
@@ -116,12 +118,17 @@ const discordDescriptionLimit = 4096
|
||||
|
||||
// buildRecommendationEmbeds renders the buy and sell recommendations as
|
||||
// separate Discord embeds, one per non-empty list. If there are no
|
||||
// recommendations at all, it returns nil and no message is sent — an empty
|
||||
// buy/sell call isn't worth a Discord notification.
|
||||
// recommendations at all, it returns nil — an empty buy/sell call gets no
|
||||
// embed of its own, though the portfolio embed (see buildPortfolioEmbed)
|
||||
// is still always sent alongside whatever this returns.
|
||||
func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
|
||||
var buyLines, sellLines []string
|
||||
for _, r := range recs {
|
||||
line := fmt.Sprintf("**%s** — %s", r.Symbol, r.Reason)
|
||||
leverage := r.Leverage
|
||||
if leverage <= 0 {
|
||||
leverage = 1
|
||||
}
|
||||
line := fmt.Sprintf("**%s** — $%.2f at %.2gx — %s", r.Symbol, r.AmountUSD, leverage, r.Reason)
|
||||
if r.Action == "sell" {
|
||||
sellLines = append(sellLines, line)
|
||||
} else {
|
||||
@@ -139,6 +146,49 @@ func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
|
||||
return embeds
|
||||
}
|
||||
|
||||
// buildPortfolioEmbed renders the paper-trading portfolio's post-trade state
|
||||
// — cash, equity, total profit, this run's executed trades, and any open
|
||||
// positions — computed entirely by internal/portfolio, not Claude. Always
|
||||
// returns an embed (never nil) so the profit update is visible every run.
|
||||
func buildPortfolioEmbed(p report.PortfolioSnapshot) map[string]any {
|
||||
lines := []string{
|
||||
fmt.Sprintf("Cash: $%.2f — Equity: $%.2f", p.CashUSD, p.EquityUSD),
|
||||
fmt.Sprintf("Total P&L: $%.2f (%.2f%%) since $%.2f start", p.TotalProfitUSD, p.TotalProfitPct, p.InitialBudgetUSD),
|
||||
}
|
||||
|
||||
if len(p.Trades) == 0 {
|
||||
lines = append(lines, "No trades executed this run.")
|
||||
} else {
|
||||
lines = append(lines, "", "**Trades this run:**")
|
||||
for _, t := range p.Trades {
|
||||
status := "skipped"
|
||||
if t.Executed {
|
||||
status = "executed"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s @ %.4g (%s): %s", strings.ToUpper(t.Action), t.Symbol, t.Price, status, t.Note)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.Positions) > 0 {
|
||||
lines = append(lines, "", "**Open positions:**")
|
||||
for _, pos := range p.Positions {
|
||||
lines = append(lines, fmt.Sprintf("%s %s: %.6g units, entry %.4g, now %.4g, unrealized $%.2f",
|
||||
pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, pos.CurrentPrice, pos.UnrealizedPnL))
|
||||
}
|
||||
}
|
||||
|
||||
color := colorBuy
|
||||
if p.TotalProfitUSD < 0 {
|
||||
color = colorSell
|
||||
}
|
||||
return map[string]any{
|
||||
"title": "💰 Portfolio",
|
||||
"color": color,
|
||||
"description": truncateForDescription(strings.Join(lines, "\n")),
|
||||
}
|
||||
}
|
||||
|
||||
func recommendationEmbed(title string, color int, lines []string) map[string]any {
|
||||
return map[string]any{
|
||||
"title": title,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,15 +4,68 @@ package report
|
||||
|
||||
// Recommendation is a single buy/sell call with a short reason, surfaced
|
||||
// separately from the main narrative report (e.g. as a Discord embed).
|
||||
//
|
||||
// AmountUSD and Leverage are Claude's requested position size and leverage —
|
||||
// a decision, not a calculation. The actual quantity, margin, and any
|
||||
// resulting profit/loss are computed deterministically by internal/portfolio
|
||||
// from the fetched price at execution time, never by Claude; Go also clamps
|
||||
// AmountUSD to available cash and Leverage to the configured maximum, so
|
||||
// these fields are a request, not a guarantee of what was executed (see
|
||||
// PortfolioSnapshot.Trades for what actually happened).
|
||||
type Recommendation struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // "buy" or "sell"
|
||||
Reason string `json:"reason"`
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // "buy" or "sell"
|
||||
Reason string `json:"reason"`
|
||||
AmountUSD float64 `json:"amount_usd"` // requested USD margin to commit, before leverage
|
||||
Leverage float64 `json:"leverage"` // requested leverage multiplier, e.g. 1 = no leverage
|
||||
}
|
||||
|
||||
// Position is one open, leveraged paper-trading position, marked to market
|
||||
// at the current fetched price.
|
||||
type Position struct {
|
||||
Symbol string
|
||||
Side string // "long" or "short"
|
||||
Quantity float64
|
||||
EntryPrice float64
|
||||
CurrentPrice float64
|
||||
Margin float64 // USD committed, before leverage
|
||||
Leverage float64
|
||||
UnrealizedPnL float64
|
||||
}
|
||||
|
||||
// TradeExecution records what internal/portfolio actually did with one
|
||||
// Recommendation at fetch-time prices — which may differ from what was
|
||||
// requested (e.g. clamped to available cash, or skipped entirely).
|
||||
type TradeExecution struct {
|
||||
Symbol string
|
||||
Action string
|
||||
Price float64 // fetch-time price the trade executed at
|
||||
AmountUSD float64 // USD margin actually committed
|
||||
Leverage float64 // leverage actually applied
|
||||
Executed bool
|
||||
Note string // what happened, or why it was skipped
|
||||
RealizedPnL float64
|
||||
}
|
||||
|
||||
// PortfolioSnapshot is the paper-trading portfolio's state after applying
|
||||
// this run's trades, computed entirely by internal/portfolio — never by
|
||||
// Claude — from the fetched prices.
|
||||
type PortfolioSnapshot struct {
|
||||
InitialBudgetUSD float64
|
||||
CashUSD float64
|
||||
EquityUSD float64 // cash + margin + unrealized P&L of all open positions
|
||||
TotalProfitUSD float64 // EquityUSD - InitialBudgetUSD
|
||||
TotalProfitPct float64
|
||||
Positions []Position
|
||||
Trades []TradeExecution // this run's executions
|
||||
}
|
||||
|
||||
// Report is the full output of one analysis run: the long-form narrative
|
||||
// text plus an optional short list of buy/sell recommendations.
|
||||
// text, an optional short list of buy/sell recommendations, the resulting
|
||||
// portfolio state, and Claude's freeform memory notes for future runs.
|
||||
type Report struct {
|
||||
Text string
|
||||
Recommendations []Recommendation
|
||||
Portfolio PortfolioSnapshot
|
||||
Memory string
|
||||
}
|
||||
Reference in new issue
Block a user