From ba63c5deb0817002ed8cd15f8c5dda59895992b6 Mon Sep 17 00:00:00 2001 From: Yi-Ting Shih Date: Mon, 7 Sep 2026 09:04:34 +0800 Subject: [PATCH] feat: add prediction and omit empty Buy/Sell --- cmd/stonks/main.go | 14 ++++++---- internal/analyze/prompt.go | 45 ++++++++++++++++++++---------- internal/archive/archive.go | 55 +++++++++++++++++++++++++------------ internal/config/config.go | 7 +++-- internal/output/discord.go | 32 +++++++++++---------- 5 files changed, 99 insertions(+), 54 deletions(-) diff --git a/cmd/stonks/main.go b/cmd/stonks/main.go index bdd65c0..6e9791a 100644 --- a/cmd/stonks/main.go +++ b/cmd/stonks/main.go @@ -52,14 +52,18 @@ func run(configPath string, timeout time.Duration) error { return fmt.Errorf("build analyzer: %w", err) } - previousReport, err := archive.LoadLatest(cfg.Archive) + recentRuns, err := archive.LoadRecent(cfg.Archive, 5) if err != nil { - // Missing/unreadable previous report just means this briefing can't - // dedup against it — not fatal. - log.Printf("warning: could not load previous archived report for dedup: %v", err) + // Missing/unreadable previous reports just means this briefing can't + // dedup against them — not fatal. + log.Printf("warning: could not load previous archived reports for dedup: %v", err) + } + previous := make([]analyze.PreviousBriefing, len(recentRuns)) + for i, r := range recentRuns { + previous[i] = analyze.PreviousBriefing{When: r.When, Text: r.Report} } - prompt := analyze.BuildUserPrompt(cfg.Targets, result, previousReport) + prompt := analyze.BuildUserPrompt(cfg.Targets, result, previous) if len(cfg.Analyzer.Advisors) > 0 { advisorResults := analyze.RunAdvisors(ctx, cfg.Analyzer.Advisors, prompt) diff --git a/internal/analyze/prompt.go b/internal/analyze/prompt.go index 80c7379..28fceaa 100644 --- a/internal/analyze/prompt.go +++ b/internal/analyze/prompt.go @@ -18,7 +18,7 @@ You will be given today's prices (with percent change) and recent news headlines Structure your response in Markdown, formatted for Discord (short lines, ` + "`**bold**`" + ` section headers, no HTML, no huge tables). Use exactly these section headers, in exactly this order, every single time — do not rename, reorder, merge, or skip a section (write "nothing notable" under a section rather than omitting it): -1. **Summary** — one paragraph, market-wide. +1. **Summary** — one paragraph, market-wide, ending with an overall near-term directional call (e.g. risk-on, risk-off, mixed/range-bound). 2. **Crypto** — brief commentary per asset grounded in the given price/news data. 3. **NASDAQ Equities** — same, grouped sensibly (e.g. by sector) if useful. 4. **Futures** — gold, silver, crude oil. @@ -27,14 +27,19 @@ Structure your response in Markdown, formatted for Discord (short lines, ` + "`* Keep the whole message under roughly 1500 words. Ground every claim in the provided data — do not fabricate news or prices. If data for an asset is missing, say so briefly rather than inventing a view. End with a one-line disclaimer that this is not financial advice. -## Deduplicating against the previous briefing +## Predictions -If the user turn includes a section titled "Previous Briefing", that was posted about 5 hours ago to the same channel — treat it as what the reader already knows, not as data to re-derive conclusions from. Since reports repeat several times a day: -- Do not re-explain news items, price moves, or commentary that already appeared there and have not materially changed since. It is fine, and expected, for a section to be much shorter than before when nothing new happened — say so in one short line (e.g. "Crypto: no material change since last briefing.") rather than restating the same analysis in new words. -- Always still report today's current price and percent change for every asset — that number itself is never a repeat, only the commentary around it can be. +Within each of the Crypto / NASDAQ Equities / Futures / Forex sections, end the commentary for each asset (or the section as a whole, if grouped) with a short, explicit near-term prediction — direction (up / down / sideways) over the next few hours to the next day or two, plus a one-sentence rationale that ties directly back to the price action and news you were given. Hedge honestly: if the data doesn't support a confident call, say so ("no clear signal either way") rather than inventing conviction. Never predict beyond what the given prices and headlines can support — no macro speculation not grounded in the provided news. + +## Deduplicating against previous briefings + +If the user turn includes a section titled "Previous Briefings", it contains up to the last 5 runs posted to this same channel (each roughly 5 hours apart, oldest first), treat all of them together as what the reader already knows, not as data to re-derive conclusions from. Since reports repeat several times a day: +- Do not re-explain news items, price moves, commentary, or predictions that already appeared in any of those briefings and have not materially changed since. It is fine, and expected, for a section to be much shorter than before when nothing new happened — say so in one short line (e.g. "Crypto: no material change since last briefing.") rather than restating the same analysis in new words. +- Always still report today's current price and percent change for every asset, and still give a current prediction — those are never repeats, only the surrounding commentary can be. +- Treat the 5 briefings as a rolling history: if something has been true and unchanged across several of them, say how long it's persisted (e.g. "still range-bound since ~15h ago") instead of re-describing it each time. - Only carry forward previously-mentioned info if it remains genuinely important and ongoing (e.g. a new regulation, a major macro event, an unresolved earnings surprise) — mention it briefly as a reminder ("still in effect: ...") rather than re-explaining it in full. - 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 Briefing section (first-ever run), write the full briefing normally. +- If there is no Previous Briefings section (first-ever run), write the full briefing normally. After the full briefing above, on its own line, output exactly: ===RECOMMENDATIONS_JSON=== @@ -42,19 +47,31 @@ followed immediately by a single JSON object (no markdown code fence, no comment {"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.` +// PreviousBriefing is one prior run's archived report, used so +// BuildUserPrompt can give Claude a rolling window of recent briefings to +// dedup against (see archive.LoadRecent). +type PreviousBriefing struct { + When time.Time + Text string +} + // BuildUserPrompt renders the fetched result into the user-turn text sent -// alongside SystemPrompt. previousReport is the narrative text of the last -// archived briefing (see archive.LoadLatest), used so Claude can dedup this -// briefing against it; pass "" if none exists yet (e.g. first-ever run). -func BuildUserPrompt(targets config.Targets, result fetch.Result, previousReport string) string { +// 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 { var b strings.Builder fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02")) - if previousReport != "" { - b.WriteString("## Previous Briefing (posted ~5 hours ago — see system prompt for how to use this)\n\n") - b.WriteString(previousReport) - b.WriteString("\n\n") + 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 { + fmt.Fprintf(&b, "### Briefing from %s (~%s ago)\n\n", p.When.Format("2006-01-02 15:04 MST"), time.Since(p.When).Round(time.Minute)) + b.WriteString(p.Text) + b.WriteString("\n\n") + } } b.WriteString("## Prices\n\n") diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 5de7dc8..4c23da4 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -67,22 +67,35 @@ func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) { return dir, nil } -// LoadLatest reads back the most recent previously-archived run's -// report.md, for the prompt to dedup the next briefing against. Returns -// ("", nil) if no prior run exists yet (e.g. first-ever run) — callers -// should treat that as "no previous report" rather than an error. -func LoadLatest(cfg config.ArchiveConfig) (string, error) { +// RecentRun is one previously-archived run's timestamp and report text, as +// returned by LoadRecent. +type RecentRun struct { + When time.Time + Report string +} + +// runDirLayout is the timestamp format run directories are named with (see +// Save), used by LoadRecent to recover each run's time. +const runDirLayout = "20060102T150405Z" + +// LoadRecent reads back up to the last n previously-archived runs' report.md +// contents, oldest first, for the prompt to dedup the next briefing against +// a rolling window of recent history. Returns (nil, nil) if no prior run +// exists yet (e.g. first-ever run) — callers should treat that as "no +// previous reports" rather than an error. Runs whose report.md is missing or +// unreadable are skipped rather than failing the whole call. +func LoadRecent(cfg config.ArchiveConfig, n int) ([]RecentRun, error) { base, err := resolveDir(cfg) if err != nil { - return "", err + return nil, err } entries, err := os.ReadDir(base) if err != nil { if os.IsNotExist(err) { - return "", nil + return nil, nil } - return "", fmt.Errorf("archive: list %q: %w", base, err) + return nil, fmt.Errorf("archive: list %q: %w", base, err) } var runDirs []string @@ -92,19 +105,27 @@ func LoadLatest(cfg config.ArchiveConfig) (string, error) { } } if len(runDirs) == 0 { - return "", nil + return nil, nil } sort.Strings(runDirs) // timestamp-named dirs sort chronologically - latest := runDirs[len(runDirs)-1] - data, err := os.ReadFile(filepath.Join(base, latest, "report.md")) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", fmt.Errorf("archive: read previous report: %w", err) + if len(runDirs) > n { + runDirs = runDirs[len(runDirs)-n:] } - return string(data), nil + + var runs []RecentRun + for _, name := range runDirs { + when, err := time.Parse(runDirLayout, name) + if err != nil { + continue + } + data, err := os.ReadFile(filepath.Join(base, name, "report.md")) + if err != nil { + continue + } + runs = append(runs, RecentRun{When: when.UTC(), Report: string(data)}) + } + return runs, nil } // resolveDir returns cfg.Dir (or defaultDir if empty) with "~" expanded. diff --git a/internal/config/config.go b/internal/config/config.go index 15fc970..a4e7a1f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,9 +76,10 @@ type AdvisorConfig struct { } // ArchiveConfig controls saving a local copy of each run's fetched data and -// analysis. Archiving is always on: besides being a debugging aid, the -// previous run's report.md is read back on every run to let the prompt -// dedup against it (see analyze.BuildUserPrompt). +// analysis. Archiving is always on: besides being a debugging aid, the last +// few runs' report.md files are read back on every run to let the prompt +// dedup against that rolling history (see archive.LoadRecent, +// analyze.BuildUserPrompt). type ArchiveConfig struct { // Dir is the directory each run's files are written under, one // timestamped subdirectory per run. Supports a leading "~". Defaults to diff --git a/internal/output/discord.go b/internal/output/discord.go index a23efc7..83024d6 100644 --- a/internal/output/discord.go +++ b/internal/output/discord.go @@ -63,9 +63,10 @@ func (d *discordOutput) Send(ctx context.Context, rep report.Report) error { } } - embeds := buildRecommendationEmbeds(rep.Recommendations) - if err := d.postEmbeds(ctx, embeds); err != nil { - return fmt.Errorf("discord: send recommendation embeds: %w", err) + if embeds := buildRecommendationEmbeds(rep.Recommendations); len(embeds) > 0 { + if err := d.postEmbeds(ctx, embeds); err != nil { + return fmt.Errorf("discord: send recommendation embeds: %w", err) + } } return nil } @@ -113,9 +114,10 @@ const ( // embed's `description`. const discordDescriptionLimit = 4096 -// buildRecommendationEmbeds renders the buy and sell recommendations as two -// separate Discord embeds (always both, even when one or both lists are -// empty, so "nothing to report" is stated explicitly rather than omitted). +// 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. func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any { var buyLines, sellLines []string for _, r := range recs { @@ -127,21 +129,21 @@ func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any { } } - return []map[string]any{ - recommendationEmbed("🟢 Buy", colorBuy, buyLines, "No buy recommendations today."), - recommendationEmbed("🔴 Sell", colorSell, sellLines, "No sell recommendations today."), + var embeds []map[string]any + if len(buyLines) > 0 { + embeds = append(embeds, recommendationEmbed("🟢 Buy", colorBuy, buyLines)) } + if len(sellLines) > 0 { + embeds = append(embeds, recommendationEmbed("🔴 Sell", colorSell, sellLines)) + } + return embeds } -func recommendationEmbed(title string, color int, lines []string, emptyText string) map[string]any { - description := emptyText - if len(lines) > 0 { - description = truncateForDescription(strings.Join(lines, "\n")) - } +func recommendationEmbed(title string, color int, lines []string) map[string]any { return map[string]any{ "title": title, "color": color, - "description": description, + "description": truncateForDescription(strings.Join(lines, "\n")), } }