-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
304 lines (281 loc) · 7.41 KB
/
Copy pathmain.go
File metadata and controls
304 lines (281 loc) · 7.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
// Command stock-tracker is a CLI trend monitor for a list of stocks/ETFs.
// For each tracked symbol it fetches 5-day hourly data from Yahoo Finance,
// computes trend/volume/signal metrics and position P&L, detects changes vs the
// previous run, and prints a compact report. Designed to run on a fixed
// interval by cron.
//
// Optional: if OPENAI_API_KEY is set, a cached AI "why it moved" news line is
// added on meaningful moves (see the AI_EXPLAIN_* / AI_CACHE_* env vars).
package main
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
// Embed the timezone database so America/New_York resolves even on systems
// without system tzdata (keeps market-session math correct year-round).
_ "time/tzdata"
"stocktracker/internal/ai"
"stocktracker/internal/config"
"stocktracker/internal/monitor"
"stocktracker/internal/positions"
"stocktracker/internal/stock"
"stocktracker/internal/store"
)
const usage = `stock-tracker — CLI stock trend monitor (Yahoo Finance)
Usage:
stock-tracker track [--loop] [--interval SECONDS]
Run one monitoring pass (the cron entry point).
With --loop, run continuously (default 1800s).
stock-tracker list Show the tracked symbols.
stock-tracker add SYM... Add symbol(s) to the tracker list.
stock-tracker remove SYM... Remove symbol(s) from the tracker list.
stock-tracker position set SYM QTY AVG
Record/update a held position (also tracks SYM).
stock-tracker position list Show recorded positions.
stock-tracker position remove SYM
Delete a recorded position.
stock-tracker price SYM... Print the current price for symbol(s).
stock-tracker research SYM...
Force a one-off AI news summary for symbol(s).
stock-tracker help Show this help.
`
func main() {
config.LoadDotenv(".env")
if err := store.Ensure(); err != nil {
fatal(err)
}
args := os.Args[1:]
if len(args) == 0 {
fmt.Print(usage)
os.Exit(1)
}
cmd, rest := args[0], args[1:]
var err error
switch cmd {
case "track":
err = cmdTrack(rest)
case "list":
err = cmdList()
case "add":
err = cmdAdd(rest)
case "remove":
err = cmdRemove(rest)
case "position", "positions":
err = cmdPosition(rest)
case "price":
err = cmdPrice(rest)
case "research":
err = cmdResearch(rest)
case "help", "-h", "--help":
fmt.Print(usage)
default:
fmt.Printf("unknown command: %s\n\n%s", cmd, usage)
os.Exit(1)
}
if err != nil {
fatal(err)
}
}
func cmdTrack(args []string) error {
loop := false
interval := 1800
for i := 0; i < len(args); i++ {
switch args[i] {
case "--loop":
loop = true
case "--interval":
if i+1 >= len(args) {
return fmt.Errorf("--interval requires a value in seconds")
}
n, err := strconv.Atoi(args[i+1])
if err != nil || n <= 0 {
return fmt.Errorf("invalid --interval: %s", args[i+1])
}
interval = n
i++
default:
return fmt.Errorf("unknown flag for track: %s", args[i])
}
}
if !loop {
anyError, err := monitor.Run()
if err != nil {
return err
}
if anyError {
os.Exit(1) // let cron error-alerting fire
}
return nil
}
fmt.Printf("Starting continuous monitoring every %ds. Ctrl+C to stop.\n", interval)
for {
if _, err := monitor.Run(); err != nil {
fmt.Printf("monitor error: %v\n", err)
}
time.Sleep(time.Duration(interval) * time.Second)
}
}
func cmdList() error {
symbols, err := store.LoadTrackers()
if err != nil {
return err
}
if len(symbols) == 0 {
fmt.Println("Tracker list is empty. Add one with: stock-tracker add AAPL")
return nil
}
fmt.Println("Tracking:")
for _, s := range symbols {
fmt.Printf(" - %s\n", s)
}
return nil
}
func cmdAdd(symbols []string) error {
if len(symbols) == 0 {
return fmt.Errorf("add requires at least one symbol")
}
for _, s := range symbols {
added, err := store.AddTracker(s)
if err != nil {
return err
}
if added {
fmt.Printf("Added %s.\n", strings.ToUpper(s))
} else {
fmt.Printf("%s is already tracked.\n", strings.ToUpper(s))
}
}
return nil
}
func cmdRemove(symbols []string) error {
if len(symbols) == 0 {
return fmt.Errorf("remove requires at least one symbol")
}
for _, s := range symbols {
removed, err := store.RemoveTracker(s)
if err != nil {
return err
}
if removed {
fmt.Printf("Removed %s.\n", strings.ToUpper(s))
} else {
fmt.Printf("%s was not in the tracker list.\n", strings.ToUpper(s))
}
}
return nil
}
func cmdPosition(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: position <set|list|remove> ...")
}
sub, rest := args[0], args[1:]
switch sub {
case "list":
m, err := positions.Load()
if err != nil {
return err
}
if len(m) == 0 {
fmt.Println("No positions recorded. Add one with: stock-tracker position set SLV 970 59.55")
return nil
}
syms := make([]string, 0, len(m))
for s := range m {
syms = append(syms, s)
}
sort.Strings(syms)
fmt.Println("Positions:")
for _, s := range syms {
p := m[s]
fmt.Printf(" - %s: %g @ $%.2f (cost $%.2f)\n", s, p.Qty, p.Avg, p.Qty*p.Avg)
}
return nil
case "set":
if len(rest) != 3 {
return fmt.Errorf("usage: position set SYM QTY AVG (e.g. position set SLV 970 59.55)")
}
sym := strings.ToUpper(rest[0])
qty, err := strconv.ParseFloat(rest[1], 64)
if err != nil || qty <= 0 {
return fmt.Errorf("invalid QTY: %s", rest[1])
}
avg, err := strconv.ParseFloat(rest[2], 64)
if err != nil || avg <= 0 {
return fmt.Errorf("invalid AVG: %s", rest[2])
}
if err := positions.Set(sym, qty, avg); err != nil {
return err
}
added, err := store.AddTracker(sym) // a position implies we want to monitor it
if err != nil {
return err
}
fmt.Printf("Set %s: %g @ $%.2f (cost $%.2f).\n", sym, qty, avg, qty*avg)
if added {
fmt.Printf("Also added %s to the tracker list.\n", sym)
}
return nil
case "remove":
if len(rest) != 1 {
return fmt.Errorf("usage: position remove SYM")
}
sym := strings.ToUpper(rest[0])
removed, err := positions.Remove(sym)
if err != nil {
return err
}
if removed {
fmt.Printf("Removed position %s. (still tracked; use 'remove %s' to untrack)\n", sym, sym)
} else {
fmt.Printf("%s had no recorded position.\n", sym)
}
return nil
default:
return fmt.Errorf("unknown position subcommand: %s (use set|list|remove)", sub)
}
}
func cmdPrice(symbols []string) error {
if len(symbols) == 0 {
return fmt.Errorf("price requires at least one symbol")
}
for _, s := range symbols {
q, err := stock.GetQuote(strings.ToUpper(s))
if err != nil {
fmt.Printf("%s: error: %v\n", strings.ToUpper(s), err)
continue
}
fmt.Printf("%s: %.2f (prev close %.2f, %+.2f%%)\n",
q.Symbol, q.CurrentPrice, q.PreviousClose, q.ChangePct()*100)
}
return nil
}
func cmdResearch(symbols []string) error {
if len(symbols) == 0 {
return fmt.Errorf("research requires at least one symbol")
}
if !ai.Enabled() {
return fmt.Errorf("research requires OPENAI_API_KEY to be set")
}
for _, s := range symbols {
sym := strings.ToUpper(s)
q, err := stock.GetQuote(sym)
if err != nil {
fmt.Printf("%s: error: %v\n", sym, err)
continue
}
msg, err := ai.Research(sym, q.CurrentPrice, q.PreviousClose)
if err != nil {
fmt.Printf("%s: error: %v\n", sym, err)
continue
}
fmt.Println(msg)
}
return nil
}
func fatal(err error) {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}