remove old bloat
This commit is contained in:
@@ -1,15 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- HTTP Handlers ----------
|
||||
@@ -26,69 +21,20 @@ func HelloHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "Hello You")
|
||||
}
|
||||
|
||||
func Home(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/index.html") }
|
||||
func Portfolio(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "./static/portfolio.html")
|
||||
}
|
||||
func Home(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/index.html") }
|
||||
func Infra(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/infra.html") }
|
||||
func Finance(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/finance.html") }
|
||||
func Cinema(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/cinema.html") }
|
||||
func Cyber(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/cyber.html") }
|
||||
func Engine(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/engine.html") }
|
||||
func About(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/about.html") }
|
||||
func Portfolio(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "./static/portfolio.html")
|
||||
}
|
||||
func About(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/about.html") }
|
||||
|
||||
func Styles(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/styles.css") }
|
||||
func Styles2(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/styles2.css") }
|
||||
|
||||
// TradesHandler handles POST /trades
|
||||
// It accepts a CSV file upload (field name: "file") and returns enriched trades as JSON.
|
||||
//
|
||||
// Example curl:
|
||||
//
|
||||
// curl -X POST http://localhost:8081/trades -F "file=@trades.csv"
|
||||
func TradesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "only POST is supported", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form (max 10MB)
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("Trades.csc")
|
||||
if err != nil {
|
||||
http.Error(w, `missing "file" field in form-data`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Parse CSV
|
||||
reader := csv.NewReader(file)
|
||||
reader.LazyQuotes = true
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
http.Error(w, "invalid CSV: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use most recent weekday as price date
|
||||
priceDate := lastWeekday(time.Now().UTC().AddDate(0, 0, -1))
|
||||
|
||||
// Enrich and return as JSON
|
||||
enriched, err := enrichRecords(records, priceDate)
|
||||
if err != nil {
|
||||
http.Error(w, "enrichment failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(enriched)
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
func main() {
|
||||
@@ -110,6 +56,8 @@ func main() {
|
||||
http.HandleFunc("/cinema", Cinema)
|
||||
http.HandleFunc("/cyber", Cyber)
|
||||
http.HandleFunc("/engine", Engine)
|
||||
http.HandleFunc("/portfolio", Portfolio)
|
||||
|
||||
http.HandleFunc("/git", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "https://git.samantha42.xyz", http.StatusFound)
|
||||
})
|
||||
@@ -119,255 +67,3 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Trade type ----------
|
||||
|
||||
type Trade struct {
|
||||
DataDiscriminator string `json:"data_discriminator"`
|
||||
AssetCategory string `json:"asset_category"`
|
||||
Currency string `json:"currency"`
|
||||
Symbol string `json:"symbol"`
|
||||
DateTime string `json:"date_time"`
|
||||
Quantity string `json:"quantity"`
|
||||
TradePrice string `json:"trade_price"`
|
||||
CurrentPrice float64 `json:"current_price"` // enriched from EOD
|
||||
Proceeds string `json:"proceeds"`
|
||||
CommFee string `json:"comm_fee"`
|
||||
Basis string `json:"basis"`
|
||||
RealizedPL string `json:"realized_pl"`
|
||||
MTMLPL string `json:"mtm_pl"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// ---------- Enrich logic ----------
|
||||
|
||||
func enrichRecords(records [][]string, priceDate time.Time) ([]Trade, error) {
|
||||
// Collect unique tickers from Data/Order rows
|
||||
type key struct{ symbol, currency string }
|
||||
needed := map[key]struct{}{}
|
||||
|
||||
for _, row := range records {
|
||||
if len(row) <= colCPrice {
|
||||
continue
|
||||
}
|
||||
if row[colType] == "Trades" && row[colHeader] == "Data" && row[colDataDisc] == "Order" {
|
||||
sym := strings.TrimSpace(row[colSymbol])
|
||||
cur := strings.TrimSpace(row[colCurrency])
|
||||
if t := toYahooTicker(sym, cur); t != "" {
|
||||
needed[key{t, cur}] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch prices concurrently
|
||||
prices := map[string]float64{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for k := range needed {
|
||||
wg.Add(1)
|
||||
go func(ticker string) {
|
||||
defer wg.Done()
|
||||
eod, err := FetchEOD(ticker, priceDate)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("[warn] %s: %v", ticker, err)
|
||||
} else {
|
||||
prices[ticker] = eod.Close
|
||||
}
|
||||
}(k.symbol)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Build response
|
||||
var trades []Trade
|
||||
for _, row := range records {
|
||||
if len(row) <= colCPrice {
|
||||
continue
|
||||
}
|
||||
if row[colType] != "Trades" || row[colHeader] != "Data" || row[colDataDisc] != "Order" {
|
||||
continue
|
||||
}
|
||||
|
||||
sym := strings.TrimSpace(row[colSymbol])
|
||||
cur := strings.TrimSpace(row[colCurrency])
|
||||
ticker := toYahooTicker(sym, cur)
|
||||
|
||||
var currentPrice float64
|
||||
if ticker != "" {
|
||||
currentPrice = prices[ticker]
|
||||
}
|
||||
|
||||
t := Trade{
|
||||
DataDiscriminator: strings.TrimSpace(row[colDataDisc]),
|
||||
AssetCategory: strings.TrimSpace(row[colAssetCategory]),
|
||||
Currency: cur,
|
||||
Symbol: sym,
|
||||
DateTime: safeCol(row, colDateTime),
|
||||
Quantity: safeCol(row, colQuantity),
|
||||
TradePrice: safeCol(row, colTPrice),
|
||||
CurrentPrice: currentPrice,
|
||||
Proceeds: safeCol(row, 10),
|
||||
CommFee: safeCol(row, 11),
|
||||
Basis: safeCol(row, 12),
|
||||
RealizedPL: safeCol(row, 13),
|
||||
MTMLPL: safeCol(row, 14),
|
||||
Code: safeCol(row, 15),
|
||||
}
|
||||
trades = append(trades, t)
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
func safeCol(row []string, i int) string {
|
||||
if i < len(row) {
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lastWeekday(t time.Time) time.Time {
|
||||
for t.Weekday() == time.Saturday || t.Weekday() == time.Sunday {
|
||||
t = t.AddDate(0, 0, -1)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
const (
|
||||
colType = 0
|
||||
colHeader = 1
|
||||
colDataDisc = 2
|
||||
colAssetCategory = 3
|
||||
colCurrency = 4
|
||||
colSymbol = 5
|
||||
colDateTime = 6
|
||||
colQuantity = 7
|
||||
colTPrice = 8
|
||||
colCPrice = 9
|
||||
)
|
||||
|
||||
var tickerOverrides = map[string]string{
|
||||
"NOVOBc": "NOVO-B.CO",
|
||||
}
|
||||
|
||||
func toYahooTicker(ibkrSymbol, currency string) string {
|
||||
if override, ok := tickerOverrides[ibkrSymbol]; ok {
|
||||
return override
|
||||
}
|
||||
if strings.Contains(ibkrSymbol, " ") {
|
||||
return "" // skip options
|
||||
}
|
||||
return ibkrSymbol
|
||||
}
|
||||
|
||||
type EODPrice struct {
|
||||
Ticker string
|
||||
Date string
|
||||
Open float64
|
||||
High float64
|
||||
Low float64
|
||||
Close float64
|
||||
Currency string
|
||||
LatestPrice float64 // regularMarketPrice from meta — most recent quote
|
||||
LatestTime time.Time
|
||||
PreviousClose float64
|
||||
ChangePercent float64
|
||||
}
|
||||
|
||||
func FetchEOD(ticker string, date time.Time) (*EODPrice, error) {
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(24 * time.Hour)
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://query1.finance.yahoo.com/v8/finance/chart/%s?period1=%d&period2=%d&interval=1d",
|
||||
ticker, start.Unix(), end.Unix(),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var payload struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Currency string `json:"currency"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
RegularMarketTime int64 `json:"regularMarketTime"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
RegularMarketChangePercent float64 `json:"regularMarketChangePercent"`
|
||||
} `json:"meta"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Open []float64 `json:"open"`
|
||||
High []float64 `json:"high"`
|
||||
Low []float64 `json:"low"`
|
||||
Close []float64 `json:"close"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"error"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload.Chart.Error != nil {
|
||||
return nil, fmt.Errorf("yahoo: %s", payload.Chart.Error.Description)
|
||||
}
|
||||
if len(payload.Chart.Result) == 0 {
|
||||
return nil, fmt.Errorf("no data for %s on %s", ticker, date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
r := payload.Chart.Result[0]
|
||||
q := r.Indicators.Quote[0]
|
||||
|
||||
if len(q.Close) == 0 || q.Close[0] == 0 {
|
||||
return nil, fmt.Errorf("no close price for %s on %s", ticker, date.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
return &EODPrice{
|
||||
Ticker: ticker,
|
||||
Date: date.Format("2006-01-02"),
|
||||
Open: q.Open[0],
|
||||
High: q.High[0],
|
||||
Low: q.Low[0],
|
||||
Close: q.Close[0],
|
||||
Currency: r.Meta.Currency,
|
||||
LatestPrice: r.Meta.RegularMarketPrice,
|
||||
LatestTime: time.Unix(r.Meta.RegularMarketTime, 0).UTC(),
|
||||
PreviousClose: r.Meta.ChartPreviousClose,
|
||||
ChangePercent: r.Meta.RegularMarketChangePercent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func PricesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Tickers []string `json:"tickers"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
priceDate := lastWeekday(time.Now().UTC().AddDate(0, 0, -1))
|
||||
var results []EODPrice
|
||||
for _, t := range req.Tickers {
|
||||
eod, err := FetchEOD(t, priceDate)
|
||||
if err == nil {
|
||||
results = append(results, *eod)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(results)
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,11 +14,12 @@
|
||||
<a class="nav-logo" href="#">samantha_vero_friis</a>
|
||||
<div class="nav-links">
|
||||
<a href="/engine">engine</a>
|
||||
<a href="/portfolio">portfolio</a>
|
||||
<a href="/cyber">cyber</a>
|
||||
<a href="/cinema">cinema</a>
|
||||
<a href="/cinema">finance</a>
|
||||
<a href="/infra">infrastructure</a>
|
||||
<a href="https://samantha42.xyz/git">git</a>
|
||||
<a href="/git">git</a>
|
||||
<a class="cta" href="mailto:me@samantha42.xyz">contact</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
+452
-344
@@ -7,231 +7,199 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
/* ── Trades accordion ── */
|
||||
.trades-section {
|
||||
margin-top: 2.5rem;
|
||||
/* ── Add Trade Modal ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--bg, #0d0d0d);
|
||||
border: 1px solid var(--border, #2a2a2a);
|
||||
width: min(520px, 94vw);
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
animation: modalIn .2s ease;
|
||||
}
|
||||
@keyframes modalIn {
|
||||
from { opacity:0; transform: translateY(12px); }
|
||||
to { opacity:1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.trades-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
.modal-title {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .75rem;
|
||||
color: var(--muted, #555);
|
||||
letter-spacing: .08em;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
.modal-title span { color: var(--fg, #e8e8e8); }
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem; right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border, #2a2a3a);
|
||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||
width: 100%;
|
||||
padding: 0.85rem 0;
|
||||
color: inherit;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trades-toggle:hover .toggle-label {
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: var(--muted, #475569);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-label span {
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
color: var(--muted, #475569);
|
||||
font-size: 1rem;
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--muted, #555);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: .2rem .4rem;
|
||||
transition: color .15s;
|
||||
}
|
||||
.modal-close:hover { color: var(--fg, #e8e8e8); }
|
||||
|
||||
.trades-toggle[aria-expanded="true"] .toggle-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.trades-body {
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: .8rem 1.2rem;
|
||||
}
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
|
||||
.trades-body.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.trades-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trades-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
color: var(--muted, #475569);
|
||||
.form-field label {
|
||||
display: block;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-size: .68rem;
|
||||
color: var(--muted, #555);
|
||||
letter-spacing: .06em;
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
border-color: var(--accent, #f59e0b);
|
||||
color: var(--accent, #f59e0b);
|
||||
background: rgba(245,158,11,0.06);
|
||||
}
|
||||
|
||||
.trades-table-wrap {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
.form-field input,
|
||||
.form-field select {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.trades-table thead tr {
|
||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||
}
|
||||
|
||||
.trades-table th {
|
||||
color: var(--muted, #475569);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.07em;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trades-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(42,42,58,0.5);
|
||||
white-space: nowrap;
|
||||
color: var(--text, #e2e8f0);
|
||||
}
|
||||
|
||||
.trades-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.trades-table tbody tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.trades-table tr.hidden-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dir-buy { color: #4ade80; }
|
||||
.dir-sell { color: #f87171; }
|
||||
|
||||
.pnl-pos { color: #4ade80; }
|
||||
.pnl-neg { color: #f87171; }
|
||||
.pnl-zero{ color: var(--muted, #475569); }
|
||||
|
||||
.trade-code {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
background: rgba(42,42,58,0.8);
|
||||
color: var(--muted, #475569);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.trade-ticker {
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.trades-count {
|
||||
color: var(--muted, #475569);
|
||||
font-size: 0.68rem;
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Live price styles ── */
|
||||
.price-cell {
|
||||
position: relative;
|
||||
}
|
||||
.price-val {
|
||||
transition: color 0.4s;
|
||||
}
|
||||
.price-val.loading {
|
||||
color: var(--muted, #475569);
|
||||
}
|
||||
.price-val.flash-up {
|
||||
color: #4ade80;
|
||||
}
|
||||
.price-val.flash-down {
|
||||
color: #f87171;
|
||||
}
|
||||
.price-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted, #475569);
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.price-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted, #475569);
|
||||
display: inline-block;
|
||||
}
|
||||
.price-dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; }
|
||||
.price-dot.error { background: #f87171; }
|
||||
.price-dot.loading {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
color: var(--muted, #475569);
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
color: var(--fg, #e8e8e8);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 8px;
|
||||
font-size: .82rem;
|
||||
padding: .5rem .7rem;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.form-field input:focus,
|
||||
.form-field select:focus { border-color: #555; }
|
||||
.form-field select option { background: #1a1a1a; }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: .7rem;
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: 1px solid #2a2a2a;
|
||||
color: var(--muted, #555);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .78rem;
|
||||
padding: .5rem 1.1rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.15s;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
border-color: var(--accent, #f59e0b);
|
||||
color: var(--accent, #f59e0b);
|
||||
.btn-cancel:hover { border-color: #555; color: var(--fg, #e8e8e8); }
|
||||
|
||||
.btn-submit {
|
||||
background: var(--fg, #e8e8e8);
|
||||
border: none;
|
||||
color: #0d0d0d;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .78rem;
|
||||
font-weight: 500;
|
||||
padding: .5rem 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.section-header-row {
|
||||
.btn-submit:hover { opacity: .85; }
|
||||
.btn-submit:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
.form-error {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .72rem;
|
||||
color: #e05555;
|
||||
margin-top: .9rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
/* ── Add Trade trigger button ── */
|
||||
.add-trade-btn {
|
||||
background: none;
|
||||
border: 1px solid #2a2a2a;
|
||||
color: var(--muted, #555);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .72rem;
|
||||
letter-spacing: .06em;
|
||||
padding: .35rem .8rem;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, color .15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.add-trade-btn:hover { border-color: #888; color: var(--fg, #e8e8e8); }
|
||||
|
||||
/* ── Loading skeleton rows ── */
|
||||
.skeleton-row td {
|
||||
padding: .6rem 0;
|
||||
}
|
||||
.skeleton-cell {
|
||||
height: .7rem;
|
||||
background: linear-gradient(90deg, #1c1c1c 25%, #252525 50%, #1c1c1c 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
border-radius: 2px;
|
||||
width: 70%;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.trades-empty {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .78rem;
|
||||
color: var(--muted, #555);
|
||||
padding: 1.4rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* trades section header row flex */
|
||||
.trades-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
gap: .7rem;
|
||||
padding: .9rem 1rem .6rem;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
/* toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.8rem;
|
||||
right: 1.8rem;
|
||||
background: #1c1c1c;
|
||||
border: 1px solid #2e2e2e;
|
||||
color: var(--fg, #e8e8e8);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: .78rem;
|
||||
padding: .7rem 1.1rem;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity .2s, transform .2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.success { border-color: #3a6e3a; color: #7ecf7e; }
|
||||
.toast.error { border-color: #6e2e2e; color: #cf7e7e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -343,97 +311,44 @@
|
||||
<div class="trades-body" id="tradesBody">
|
||||
<div class="trades-inner">
|
||||
|
||||
<!-- Filter buttons -->
|
||||
<div class="trades-filter">
|
||||
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
|
||||
<button class="filter-btn" onclick="filterTrades('NOVOBc', this)">NOVOBc</button>
|
||||
<button class="filter-btn" onclick="filterTrades('CSIQ', this)">CSIQ</button>
|
||||
<button class="filter-btn" onclick="filterTrades('PYPL', this)">PYPL</button>
|
||||
<button class="filter-btn" onclick="filterTrades('options', this)">Options</button>
|
||||
<!-- Filter + Add row -->
|
||||
<div class="trades-header-row">
|
||||
<div class="trades-filter" style="margin:0;border:none;padding:0">
|
||||
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
|
||||
<button class="filter-btn" onclick="filterTrades('stock', this)">Stocks</button>
|
||||
<button class="filter-btn" onclick="filterTrades('option', this)">Options</button>
|
||||
<button class="filter-btn" onclick="filterTrades('buy', this)">Buy</button>
|
||||
<button class="filter-btn" onclick="filterTrades('sell', this)">Sell</button>
|
||||
</div>
|
||||
<button class="add-trade-btn" onclick="openAddTrade()">+ add trade</button>
|
||||
</div>
|
||||
|
||||
<p class="trades-count" id="tradesCount">10 trades</p>
|
||||
<p class="trades-count" id="tradesCount">loading…</p>
|
||||
|
||||
<div class="trades-table-wrap">
|
||||
<table class="trades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Ticker</th>
|
||||
<th>Asset</th>
|
||||
<th>Ccy</th>
|
||||
<th>Date / Time</th>
|
||||
<th>Date</th>
|
||||
<th>Dir</th>
|
||||
<th>Qty</th>
|
||||
<th>Trade Price</th>
|
||||
<th>Curr. Price</th>
|
||||
<th>Proceeds</th>
|
||||
<th>Comm/Fee</th>
|
||||
<th>Basis</th>
|
||||
<th>Realized P/L</th>
|
||||
<th>MTM P/L</th>
|
||||
<th>Code</th>
|
||||
<th>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tradesBody_rows">
|
||||
|
||||
<!-- NOVOBc -->
|
||||
<tr data-sym="NOVOBc">
|
||||
<td><span class="trade-ticker">NOVOBc</span></td>
|
||||
<td>Stocks</td><td>DKK</td>
|
||||
<td>2026-02-24 03:05</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>8</td><td>241.70</td><td>243.65</td>
|
||||
<td>−1,933.60</td><td>−10.00</td><td>1,943.60</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-pos">+15.60</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
<!-- skeleton -->
|
||||
<tr class="skeleton-row">
|
||||
<td><div class="skeleton-cell" style="width:60%"></div></td>
|
||||
<td><div class="skeleton-cell" style="width:50%"></div></td>
|
||||
<td><div class="skeleton-cell" style="width:40%"></div></td>
|
||||
<td><div class="skeleton-cell" style="width:80%"></div></td>
|
||||
<td><div class="skeleton-cell" style="width:35%"></div></td>
|
||||
<td><div class="skeleton-cell" style="width:30%"></div></td>
|
||||
<td><div class="skeleton-cell" style="width:55%"></div></td>
|
||||
</tr>
|
||||
<tr data-sym="NOVOBc">
|
||||
<td><span class="trade-ticker">NOVOBc</span></td>
|
||||
<td>Stocks</td><td>DKK</td>
|
||||
<td>2026-02-25 07:10</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>3</td><td>239.50</td><td>238.40</td>
|
||||
<td>−718.50</td><td>−10.00</td><td>728.50</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-neg">−3.30</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- CSIQ -->
|
||||
<tr data-sym="CSIQ">
|
||||
<td><span class="trade-ticker">CSIQ</span></td>
|
||||
<td>Stocks</td><td>USD</td>
|
||||
<td>2026-01-20 14:33</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>8</td><td>20.48</td><td>20.51</td>
|
||||
<td>−163.84</td><td>−0.38</td><td>164.22</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-pos">+0.24</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- PYPL -->
|
||||
<tr data-sym="PYPL">
|
||||
<td><span class="trade-ticker">PYPL</span></td>
|
||||
<td>Stocks</td><td>USD</td>
|
||||
<td>2026-01-20 14:31</td>
|
||||
<td class="dir-sell">SELL</td>
|
||||
<td>−3</td><td>55.18</td><td>55.08</td>
|
||||
<td>165.53</td><td>−0.35</td><td>−187.91</td>
|
||||
<td class="pnl-neg">−22.73</td><td class="pnl-pos">+0.29</td>
|
||||
<td><span class="trade-code">C</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Options -->
|
||||
<tr data-sym="options">
|
||||
<td><span class="trade-ticker">RGTI 15JAN27 5.5 P</span></td>
|
||||
<td>Options</td><td>USD</td>
|
||||
<td>2026-02-24 09:34</td>
|
||||
<td class="dir-buy">BUY</td>
|
||||
<td>1</td><td>0.53</td><td>0.5271</td>
|
||||
<td>−53.00</td><td>−1.05</td><td>54.05</td>
|
||||
<td class="pnl-zero">0.00</td><td class="pnl-neg">−0.29</td>
|
||||
<td><span class="trade-code">O</span></td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -448,11 +363,250 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Holdings price fetch ──
|
||||
// Calls the Go backend's /api/prices endpoint with a list of tickers.
|
||||
// Falls back to FetchEOD-style Yahoo Finance if the backend isn't available.
|
||||
<!-- ── Add Trade Modal ── -->
|
||||
<div class="modal-overlay" id="addTradeModal" onclick="handleOverlayClick(event)">
|
||||
<div class="modal">
|
||||
<p class="modal-title">// <span>add trade</span> — new executed order</p>
|
||||
<button class="modal-close" onclick="closeAddTrade()">✕</button>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label>TICKER ID</label>
|
||||
<input type="number" id="f-tickerId" min="1" placeholder="1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>SHARES</label>
|
||||
<input type="number" id="f-shares" min="1" placeholder="10" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>PRICE</label>
|
||||
<input type="number" id="f-price" step="0.01" placeholder="251.00" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>CURRENCY</label>
|
||||
<input type="text" id="f-currency" placeholder="DKK" maxlength="8" style="text-transform:uppercase"/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>PRODUCT</label>
|
||||
<select id="f-product">
|
||||
<option value="0">0 — Stock</option>
|
||||
<option value="1">1 — Option Call</option>
|
||||
<option value="2">2 — Option Put</option>
|
||||
<option value="3">3 — Currency</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>DIRECTION</label>
|
||||
<select id="f-type">
|
||||
<option value="false">Buy</option>
|
||||
<option value="true">Sell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field full">
|
||||
<label>TRADE DATE</label>
|
||||
<input type="datetime-local" id="f-date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-error" id="formError"></div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-cancel" onclick="closeAddTrade()">cancel</button>
|
||||
<button class="btn-submit" id="submitBtn" onclick="submitTrade()">submit trade</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ─────────────────────────────────────────
|
||||
// Product + Type label helpers
|
||||
// ─────────────────────────────────────────
|
||||
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency'];
|
||||
|
||||
function productLabel(n) { return PRODUCT_LABELS[n] ?? '—'; }
|
||||
function typeLabel(v) { return v ? 'SELL' : 'BUY'; }
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Trade list — GET /trade/list
|
||||
// ─────────────────────────────────────────
|
||||
let allTrades = [];
|
||||
let activeFilter = 'all';
|
||||
|
||||
async function loadTrades() {
|
||||
try {
|
||||
const res = 'http://localhost:8080/trade/list';
|
||||
if (!res.ok) throw new Error('status ' + res.status);
|
||||
const data = await res.json();
|
||||
allTrades = Array.isArray(data) ? data : [];
|
||||
} catch (err) {
|
||||
console.warn('Could not load trades:', err);
|
||||
allTrades = [];
|
||||
}
|
||||
renderTrades();
|
||||
}
|
||||
|
||||
function renderTrades() {
|
||||
const tbody = document.getElementById('tradesBody_rows');
|
||||
const countEl = document.getElementById('tradesCount');
|
||||
|
||||
const filtered = allTrades.filter(t => {
|
||||
if (activeFilter === 'all') return true;
|
||||
if (activeFilter === 'stock') return t.Product === 0;
|
||||
if (activeFilter === 'option') return t.Product === 1 || t.Product === 2;
|
||||
if (activeFilter === 'buy') return t.Type === false;
|
||||
if (activeFilter === 'sell') return t.Type === true;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="trades-empty">${allTrades.length === 0 ? 'no trades found' : 'no trades match filter'}</td></tr>`;
|
||||
countEl.textContent = '0 trades';
|
||||
return;
|
||||
}
|
||||
|
||||
countEl.textContent = filtered.length + ' trade' + (filtered.length !== 1 ? 's' : '')
|
||||
+ (activeFilter !== 'all' ? ' — ' + activeFilter : '');
|
||||
|
||||
tbody.innerHTML = filtered.map(t => {
|
||||
const ticker = t.Ticker?.Name ?? '—';
|
||||
const product = productLabel(t.Product);
|
||||
const ccy = t.Currency?.Code ?? '—';
|
||||
const date = t.Date ? new Date(t.Date).toLocaleDateString('sv-SE') : '—';
|
||||
const dir = t.Type;
|
||||
const dirCls = dir ? 'dir-sell' : 'dir-buy';
|
||||
const dirLbl = dir ? 'SELL' : 'BUY';
|
||||
const price = typeof t.Price === 'number'
|
||||
? t.Price.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '—';
|
||||
|
||||
return `<tr>
|
||||
<td><span class="trade-ticker">${ticker}</span></td>
|
||||
<td>${product}</td>
|
||||
<td>${ccy}</td>
|
||||
<td>${date}</td>
|
||||
<td class="${dirCls}">${dirLbl}</td>
|
||||
<td>${t.Shares ?? '—'}</td>
|
||||
<td>${price}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function filterTrades(sym, btn) {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
activeFilter = sym;
|
||||
renderTrades();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Add Trade Modal
|
||||
// ─────────────────────────────────────────
|
||||
function openAddTrade() {
|
||||
// Default date to now
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
document.getElementById('f-date').value = now.toISOString().slice(0, 16);
|
||||
document.getElementById('formError').textContent = '';
|
||||
document.getElementById('addTradeModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeAddTrade() {
|
||||
document.getElementById('addTradeModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function handleOverlayClick(e) {
|
||||
if (e.target === document.getElementById('addTradeModal')) closeAddTrade();
|
||||
}
|
||||
|
||||
async function submitTrade() {
|
||||
const errEl = document.getElementById('formError');
|
||||
const btn = document.getElementById('submitBtn');
|
||||
errEl.textContent = '';
|
||||
|
||||
const tickerId = parseInt(document.getElementById('f-tickerId').value);
|
||||
const shares = parseInt(document.getElementById('f-shares').value);
|
||||
const price = parseFloat(document.getElementById('f-price').value);
|
||||
const currency = document.getElementById('f-currency').value.trim().toUpperCase();
|
||||
const product = parseInt(document.getElementById('f-product').value);
|
||||
const type = document.getElementById('f-type').value === 'true';
|
||||
const dateVal = document.getElementById('f-date').value;
|
||||
|
||||
// Client-side validation
|
||||
if (!tickerId || tickerId < 1) return errEl.textContent = 'ticker id must be a positive integer';
|
||||
if (!shares || shares < 1) return errEl.textContent = 'shares must be a positive integer';
|
||||
if (!price || price <= 0) return errEl.textContent = 'price must be a positive number';
|
||||
if (!currency) return errEl.textContent = 'currency is required';
|
||||
if (!dateVal) return errEl.textContent = 'trade date is required';
|
||||
|
||||
const date = new Date(dateVal);
|
||||
if (date > new Date()) return errEl.textContent = 'date cannot be in the future';
|
||||
|
||||
const payload = {
|
||||
TickerId: tickerId,
|
||||
Shares: shares,
|
||||
Product: product,
|
||||
Type: type,
|
||||
Price: price,
|
||||
Currency: currency,
|
||||
Date: date.toISOString(),
|
||||
};
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'submitting…';
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:8080/trade/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'unknown error');
|
||||
throw new Error(msg || 'server returned ' + res.status);
|
||||
}
|
||||
|
||||
closeAddTrade();
|
||||
showToast('trade added successfully', 'success');
|
||||
await loadTrades(); // refresh list
|
||||
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || 'failed to submit trade';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'submit trade';
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Toast
|
||||
// ─────────────────────────────────────────
|
||||
function showToast(msg, type = '') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast ' + type + ' show';
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => { el.classList.remove('show'); }, 3000);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Trades accordion
|
||||
// ─────────────────────────────────────────
|
||||
function toggleTrades(btn) {
|
||||
const body = document.getElementById('tradesBody');
|
||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!expanded));
|
||||
body.classList.toggle('open', !expanded);
|
||||
// Load on first open
|
||||
if (!expanded && allTrades.length === 0) loadTrades();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// Holdings price fetch (unchanged)
|
||||
// ─────────────────────────────────────────
|
||||
const HOLDINGS = [
|
||||
{ ticker: 'MAERSK-B.CO', label: 'MEARSK' },
|
||||
{ ticker: 'DFDS.CO', label: 'DFDS' },
|
||||
@@ -462,10 +616,8 @@
|
||||
];
|
||||
|
||||
function setStatus(state, text) {
|
||||
const dot = document.getElementById('priceDot');
|
||||
const label = document.getElementById('priceStatusText');
|
||||
dot.className = 'price-dot ' + state;
|
||||
label.textContent = text;
|
||||
document.getElementById('priceDot').className = 'price-dot ' + state;
|
||||
document.getElementById('priceStatusText').textContent = text;
|
||||
}
|
||||
|
||||
function flashPrice(id, newVal, oldVal) {
|
||||
@@ -481,55 +633,42 @@
|
||||
|
||||
async function fetchHoldingPrices() {
|
||||
setStatus('loading', 'fetching prices…');
|
||||
|
||||
// Read current displayed values before overwriting
|
||||
const prev = {};
|
||||
HOLDINGS.forEach(h => {
|
||||
const el = document.getElementById('price-' + h.ticker);
|
||||
if (el) prev[h.ticker] = parseFloat(el.textContent.replace(/\./g, '').replace(',', '.')) || 0;
|
||||
});
|
||||
|
||||
try {
|
||||
// POST to Go backend — expects JSON array: [{ticker, close}, ...]
|
||||
const res = await fetch('/api/prices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tickers: HOLDINGS.map(h => h.ticker) }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('backend returned ' + res.status);
|
||||
|
||||
const data = await res.json(); // [{ ticker, close, date, currency }, ...]
|
||||
const data = await res.json();
|
||||
const map = {};
|
||||
data.forEach(d => { map[d.ticker] = d.close; });
|
||||
|
||||
HOLDINGS.forEach(h => {
|
||||
const close = map[h.ticker];
|
||||
if (close != null) flashPrice(h.ticker, close, prev[h.ticker] || close);
|
||||
});
|
||||
|
||||
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
setStatus('live', 'updated ' + now);
|
||||
|
||||
} catch (err) {
|
||||
console.warn('Backend unavailable, trying Yahoo Finance directly…', err);
|
||||
await fetchDirectYahoo(prev);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct Yahoo Finance fallback (CORS proxy) — same approach as the React artifact
|
||||
async function fetchDirectYahoo(prev) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
// Roll back past weekends
|
||||
while (yesterday.getDay() === 0 || yesterday.getDay() === 6) {
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
}
|
||||
const start = Math.floor(yesterday.setHours(0,0,0,0) / 1000);
|
||||
const end = start + 86400;
|
||||
|
||||
let ok = 0, fail = 0;
|
||||
|
||||
await Promise.allSettled(HOLDINGS.map(async h => {
|
||||
try {
|
||||
const url = `https://corsproxy.io/?${encodeURIComponent(
|
||||
@@ -538,52 +677,21 @@
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
|
||||
if (close) {
|
||||
flashPrice(h.ticker, close, prev[h.ticker] || close);
|
||||
ok++;
|
||||
} else {
|
||||
fail++;
|
||||
}
|
||||
if (close) { flashPrice(h.ticker, close, prev[h.ticker] || close); ok++; }
|
||||
else fail++;
|
||||
} catch { fail++; }
|
||||
}));
|
||||
|
||||
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
if (fail === 0) {
|
||||
setStatus('live', 'updated ' + now + ' (direct)');
|
||||
} else if (ok > 0) {
|
||||
setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
|
||||
} else {
|
||||
setStatus('error', 'price fetch failed');
|
||||
}
|
||||
if (fail === 0) setStatus('live', 'updated ' + now + ' (direct)');
|
||||
else if (ok > 0) setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
|
||||
else setStatus('error', 'price fetch failed');
|
||||
}
|
||||
|
||||
// Auto-fetch on page load
|
||||
document.addEventListener('DOMContentLoaded', fetchHoldingPrices);
|
||||
|
||||
// ── Trades accordion ──
|
||||
function toggleTrades(btn) {
|
||||
const body = document.getElementById('tradesBody');
|
||||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||||
btn.setAttribute('aria-expanded', String(!expanded));
|
||||
body.classList.toggle('open', !expanded);
|
||||
}
|
||||
|
||||
function filterTrades(sym, btn) {
|
||||
// Update active button
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const rows = document.querySelectorAll('#tradesBody_rows tr');
|
||||
let visible = 0;
|
||||
rows.forEach(row => {
|
||||
const match = sym === 'all' || row.dataset.sym === sym;
|
||||
row.classList.toggle('hidden-row', !match);
|
||||
if (match) visible++;
|
||||
});
|
||||
|
||||
document.getElementById('tradesCount').textContent =
|
||||
visible + ' trade' + (visible !== 1 ? 's' : '') + (sym !== 'all' ? ' — ' + sym : '');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchHoldingPrices();
|
||||
// Eagerly load trades so accordion is instant on first open
|
||||
loadTrades();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+226
-1
@@ -1150,4 +1150,229 @@ nav .section-label {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* ── Trades accordion ── */
|
||||
.trades-section {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.trades-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border, #2a2a3a);
|
||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||
width: 100%;
|
||||
padding: 0.85rem 0;
|
||||
color: inherit;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.trades-toggle:hover .toggle-label {
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: var(--muted, #475569);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-label span {
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
color: var(--muted, #475569);
|
||||
font-size: 1rem;
|
||||
transition: transform 0.25s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trades-toggle[aria-expanded="true"] .toggle-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.trades-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease;
|
||||
}
|
||||
|
||||
.trades-body.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.trades-inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trades-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
color: var(--muted, #475569);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
border-color: var(--accent, #f59e0b);
|
||||
color: var(--accent, #f59e0b);
|
||||
background: rgba(245,158,11,0.06);
|
||||
}
|
||||
|
||||
.trades-table-wrap {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.trades-table thead tr {
|
||||
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||
}
|
||||
|
||||
.trades-table th {
|
||||
color: var(--muted, #475569);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.07em;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trades-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(42,42,58,0.5);
|
||||
white-space: nowrap;
|
||||
color: var(--text, #e2e8f0);
|
||||
}
|
||||
|
||||
.trades-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.trades-table tbody tr:hover td {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.trades-table tr.hidden-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dir-buy { color: #4ade80; }
|
||||
.dir-sell { color: #f87171; }
|
||||
|
||||
.pnl-pos { color: #4ade80; }
|
||||
.pnl-neg { color: #f87171; }
|
||||
.pnl-zero{ color: var(--muted, #475569); }
|
||||
|
||||
.trade-code {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
background: rgba(42,42,58,0.8);
|
||||
color: var(--muted, #475569);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.trade-ticker {
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.trades-count {
|
||||
color: var(--muted, #475569);
|
||||
font-size: 0.68rem;
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Live price styles ── */
|
||||
.price-cell {
|
||||
position: relative;
|
||||
}
|
||||
.price-val {
|
||||
transition: color 0.4s;
|
||||
}
|
||||
.price-val.loading {
|
||||
color: var(--muted, #475569);
|
||||
}
|
||||
.price-val.flash-up {
|
||||
color: #4ade80;
|
||||
}
|
||||
.price-val.flash-down {
|
||||
color: #f87171;
|
||||
}
|
||||
.price-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted, #475569);
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.price-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted, #475569);
|
||||
display: inline-block;
|
||||
}
|
||||
.price-dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; }
|
||||
.price-dot.error { background: #f87171; }
|
||||
.price-dot.loading {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
color: var(--muted, #475569);
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
border-color: var(--accent, #f59e0b);
|
||||
color: var(--accent, #f59e0b);
|
||||
}
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
Reference in New Issue
Block a user