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
|
||||
|
||||
Reference in new issue
Block a user