feat: add prediction and omit empty Buy/Sell

This commit is contained in:
ytshih committed 2026-09-07 09:04:34 +08:00
1 parent 521588f992
commit ba63c5deb0
5 files changed
+99 -54

No files matched your search

+9 -5
View File
@@ -52,14 +52,18 @@ func run(configPath string, timeout time.Duration) error {
return fmt.Errorf("build analyzer: %w", err) return fmt.Errorf("build analyzer: %w", err)
} }
previousReport, err := archive.LoadLatest(cfg.Archive) recentRuns, err := archive.LoadRecent(cfg.Archive, 5)
if err != nil { if err != nil {
// Missing/unreadable previous report just means this briefing can't // Missing/unreadable previous reports just means this briefing can't
// dedup against it — not fatal. // dedup against them — not fatal.
log.Printf("warning: could not load previous archived report for dedup: %v", err) 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 { if len(cfg.Analyzer.Advisors) > 0 {
advisorResults := analyze.RunAdvisors(ctx, cfg.Analyzer.Advisors, prompt) advisorResults := analyze.RunAdvisors(ctx, cfg.Analyzer.Advisors, prompt)
+31 -14
View File
@@ -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): 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. 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. 3. **NASDAQ Equities** — same, grouped sensibly (e.g. by sector) if useful.
4. **Futures** — gold, silver, crude oil. 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. 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: 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.
- 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. ## 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. - 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 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: After the full briefing above, on its own line, output exactly:
===RECOMMENDATIONS_JSON=== ===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"}]} {"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.` "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 // BuildUserPrompt renders the fetched result into the user-turn text sent
// alongside SystemPrompt. previousReport is the narrative text of the last // alongside SystemPrompt. previous holds up to the last few archived
// archived briefing (see archive.LoadLatest), used so Claude can dedup this // briefings (oldest first, see archive.LoadRecent), used so Claude can dedup
// briefing against it; pass "" if none exists yet (e.g. first-ever run). // this briefing against that rolling history; pass nil if none exist yet
func BuildUserPrompt(targets config.Targets, result fetch.Result, previousReport string) string { // (e.g. first-ever run).
func BuildUserPrompt(targets config.Targets, result fetch.Result, previous []PreviousBriefing) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02")) fmt.Fprintf(&b, "Date: %s\n\n", time.Now().Format("2006-01-02"))
if previousReport != "" { if len(previous) > 0 {
b.WriteString("## Previous Briefing (posted ~5 hours ago — see system prompt for how to use this)\n\n") fmt.Fprintf(&b, "## Previous Briefings (last %d run(s), oldest first — see system prompt for how to use this)\n\n", len(previous))
b.WriteString(previousReport) for _, p := range previous {
b.WriteString("\n\n") 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") b.WriteString("## Prices\n\n")
+38 -17
View File
@@ -67,22 +67,35 @@ func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) {
return dir, nil return dir, nil
} }
// LoadLatest reads back the most recent previously-archived run's // RecentRun is one previously-archived run's timestamp and report text, as
// report.md, for the prompt to dedup the next briefing against. Returns // returned by LoadRecent.
// ("", nil) if no prior run exists yet (e.g. first-ever run) — callers type RecentRun struct {
// should treat that as "no previous report" rather than an error. When time.Time
func LoadLatest(cfg config.ArchiveConfig) (string, error) { 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) base, err := resolveDir(cfg)
if err != nil { if err != nil {
return "", err return nil, err
} }
entries, err := os.ReadDir(base) entries, err := os.ReadDir(base)
if err != nil { if err != nil {
if os.IsNotExist(err) { 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 var runDirs []string
@@ -92,19 +105,27 @@ func LoadLatest(cfg config.ArchiveConfig) (string, error) {
} }
} }
if len(runDirs) == 0 { if len(runDirs) == 0 {
return "", nil return nil, nil
} }
sort.Strings(runDirs) // timestamp-named dirs sort chronologically sort.Strings(runDirs) // timestamp-named dirs sort chronologically
latest := runDirs[len(runDirs)-1]
data, err := os.ReadFile(filepath.Join(base, latest, "report.md")) if len(runDirs) > n {
if err != nil { runDirs = runDirs[len(runDirs)-n:]
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("archive: read previous report: %w", err)
} }
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. // resolveDir returns cfg.Dir (or defaultDir if empty) with "~" expanded.
+4 -3
View File
@@ -76,9 +76,10 @@ type AdvisorConfig struct {
} }
// ArchiveConfig controls saving a local copy of each run's fetched data and // ArchiveConfig controls saving a local copy of each run's fetched data and
// analysis. Archiving is always on: besides being a debugging aid, the // analysis. Archiving is always on: besides being a debugging aid, the last
// previous run's report.md is read back on every run to let the prompt // few runs' report.md files are read back on every run to let the prompt
// dedup against it (see analyze.BuildUserPrompt). // dedup against that rolling history (see archive.LoadRecent,
// analyze.BuildUserPrompt).
type ArchiveConfig struct { type ArchiveConfig struct {
// Dir is the directory each run's files are written under, one // Dir is the directory each run's files are written under, one
// timestamped subdirectory per run. Supports a leading "~". Defaults to // timestamped subdirectory per run. Supports a leading "~". Defaults to
+17 -15
View File
@@ -63,9 +63,10 @@ func (d *discordOutput) Send(ctx context.Context, rep report.Report) error {
} }
} }
embeds := buildRecommendationEmbeds(rep.Recommendations) if embeds := buildRecommendationEmbeds(rep.Recommendations); len(embeds) > 0 {
if err := d.postEmbeds(ctx, embeds); err != nil { if err := d.postEmbeds(ctx, embeds); err != nil {
return fmt.Errorf("discord: send recommendation embeds: %w", err) return fmt.Errorf("discord: send recommendation embeds: %w", err)
}
} }
return nil return nil
} }
@@ -113,9 +114,10 @@ const (
// embed's `description`. // embed's `description`.
const discordDescriptionLimit = 4096 const discordDescriptionLimit = 4096
// buildRecommendationEmbeds renders the buy and sell recommendations as two // buildRecommendationEmbeds renders the buy and sell recommendations as
// separate Discord embeds (always both, even when one or both lists are // separate Discord embeds, one per non-empty list. If there are no
// empty, so "nothing to report" is stated explicitly rather than omitted). // 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 { func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
var buyLines, sellLines []string var buyLines, sellLines []string
for _, r := range recs { for _, r := range recs {
@@ -127,21 +129,21 @@ func buildRecommendationEmbeds(recs []report.Recommendation) []map[string]any {
} }
} }
return []map[string]any{ var embeds []map[string]any
recommendationEmbed("🟢 Buy", colorBuy, buyLines, "No buy recommendations today."), if len(buyLines) > 0 {
recommendationEmbed("🔴 Sell", colorSell, sellLines, "No sell recommendations today."), 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 { func recommendationEmbed(title string, color int, lines []string) map[string]any {
description := emptyText
if len(lines) > 0 {
description = truncateForDescription(strings.Join(lines, "\n"))
}
return map[string]any{ return map[string]any{
"title": title, "title": title,
"color": color, "color": color,
"description": description, "description": truncateForDescription(strings.Join(lines, "\n")),
} }
} }