feat: add prediction and omit empty Buy/Sell
This commit is contained in:
5 files changed
+99
-54
No files matched your search
+31
-14
@@ -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")
|
||||
|
||||
+38
-17
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-15
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user