diff --git a/Portifolio b/Portifolio index 61841b5..d5239e6 100755 Binary files a/Portifolio and b/Portifolio differ diff --git a/README.md b/README.md index 121fc01..7b5b069 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # Portfolio Engine -A lightweight portfolio and financial data backend written in Go. Tracks companies, currencies, and revenue reports across configurable time periods — replacing spreadsheets with a proper database, REST API, and interactive shell. +A lightweight portfolio and financial data backend written in Go. Tracks companies, currencies, revenue reports, and trades across configurable time periods — replacing spreadsheets with a proper database, REST API, and interactive shell. ## Features -- SQLite database with foreign key enforcement -- REST API on port `8080` -- Interactive shell for managing data without an HTTP client -- Revenue tracking by period (quarterly, half-year, full year) -- Revenue broken down by custom categories (product, location, segment...) -- Health endpoint with DB latency, memory, and uptime stats +* SQLite database with foreign key enforcement +* REST API on port `8080` +* Interactive shell for managing data without an HTTP client +* Revenue tracking by period (quarterly, half-year, full year) +* Revenue broken down by custom categories (product, location, segment...) +* Trade tracking (stocks, options, currency trades) +* Health endpoint with DB latency, memory, and uptime stats ## Project Structure @@ -25,12 +26,14 @@ A lightweight portfolio and financial data backend written in Go. Tracks compani ├── handlers/ │ ├── main.go # HealthHandler │ ├── currency.go # AddCurrencyHandler, GetCurrenciesHandler - │ └── revenue.go # AddRevenueEntryHandler, GetRevenueReportHandler, GetRevenueSumHandler + │ ├── revenue.go # AddRevenueEntryHandler, GetRevenueReportHandler, GetRevenueSumHandler + │ └── trade.go # AddTradeHandler, GetTradeListHandler ├── model/ │ ├── company.go # Company struct + SQL (InsertCompany, GetAllCompanies) │ ├── currency.go # Currency struct + SQL (InsertCurrency, GetAllCurrencies) │ ├── periode.go # Period struct + helpers (QuarterPeriod, HalfYearPeriod, FullYearPeriod) - │ └── revenue.go # Revenue, RevenueReport, RevSum structs + SQL + │ ├── revenue.go # Revenue, RevenueReport, RevSum structs + SQL + │ └── trade.go # Trade struct, TradeType, TradeProduct enums + SQL ├── service/ │ ├── main.go # Service wiring │ ├── company.go # Company business logic @@ -46,14 +49,14 @@ A lightweight portfolio and financial data backend written in Go. Tracks compani ### Prerequisites -- Go 1.21+ -- GCC (required for SQLite CGO bindings) - - Ubuntu/Debian: `sudo apt install gcc` - - macOS: comes with Xcode Command Line Tools +* Go 1.21+ +* GCC (required for SQLite CGO bindings) + + Ubuntu/Debian: `sudo apt install gcc` + + macOS: comes with Xcode Command Line Tools ### Install & Run -```bash +``` git clone git@git.samantha42.xyz:samantha/Portifolio-Engine.git cd Portifolio-Engine go mod download @@ -62,7 +65,7 @@ go run main.go Or build first: -```bash +``` chmod +x build.sh ./build.sh ./Portifolio @@ -89,7 +92,7 @@ The HTTP server and shell run concurrently — the API is live while you type sh Returns server status, DB connection info, memory usage, and uptime. -```bash +``` curl http://localhost:8080/health ``` @@ -120,7 +123,7 @@ Returns `503` with `"status": "degraded"` if the database is unreachable. ### `POST /add/company` -```bash +``` curl -X POST http://localhost:8080/add/company \ -H "Content-Type: application/json" \ -d '{"name":"Novo Nordisk","shares_outstanding":4442064180,"price":251.00,"currency_id":1}' @@ -130,7 +133,7 @@ curl -X POST http://localhost:8080/add/company \ ### `GET /companies` -```bash +``` curl http://localhost:8080/companies ``` @@ -138,7 +141,7 @@ curl http://localhost:8080/companies ### `POST /add/currency` -```bash +``` curl -X POST http://localhost:8080/add/currency \ -H "Content-Type: application/json" \ -d '{"code":"DKK","name":"Danish Krone"}' @@ -148,17 +151,77 @@ curl -X POST http://localhost:8080/add/currency \ ### `GET /currencies` -```bash +``` curl http://localhost:8080/currencies ``` --- +### `POST /trade/add` + +Add a new trade. `product` and `type` are integer enums (see tables below). + +``` +curl -X POST http://localhost:8080/trade/add \ + -H "Content-Type: application/json" \ + -d '{ + "ticker_id": 1, + "currency": "DKK", + "shares": 100, + "product": 0, + "type": false, + "price": 251.00, + "date": "2025-03-01T00:00:00Z" + }' +``` + +**`product` values:** + +| Value | Meaning | +|-------|------------------| +| `0` | StockTrade | +| `1` | OptionTradeCall | +| `2` | OptionTradePut | +| `3` | CurrencyTrade | + +**`type` values:** + +| Value | Meaning | +|---------|---------| +| `false` | Buy | +| `true` | Sell | + +--- + +### `GET /trade/list` + +Returns all trades as a JSON array. + +``` +curl http://localhost:8080/trade/list +``` + +```json +[ + { + "Ticker": { "id": 1, "name": "Novo Nordisk", ... }, + "Shares": 100, + "Product": 0, + "Type": false, + "Price": 251.00, + "Currency": { "id": 1, "code": "DKK", "name": "Danish Krone" }, + "Date": "2025-03-01T00:00:00Z" + } +] +``` + +--- + ### `POST /add/revenue/entry` Add a single revenue line to a report. The period is created automatically if it doesn't exist. -```bash +``` curl -X POST http://localhost:8080/add/revenue/entry \ -H "Content-Type: application/json" \ -d '{ @@ -187,7 +250,7 @@ curl -X POST http://localhost:8080/add/revenue/entry \ Get all revenue entries for a company and period. -```bash +``` curl "http://localhost:8080/revenue/report?company_id=1&period_type=Q&year=2025&index=1" ``` @@ -197,7 +260,7 @@ curl "http://localhost:8080/revenue/report?company_id=1&period_type=Q&year=2025& Sum all revenue entries for a company across all periods of a given type in a year. -```bash +``` curl "http://localhost:8080/revenue/sum?company_id=1&period_type=Q&year=2025" ``` @@ -205,63 +268,17 @@ curl "http://localhost:8080/revenue/sum?company_id=1&period_type=Q&year=2025" ## Shell Commands -| Command | Description | -|------------------|-----------------------------------------------| -| `add-company` | Add a new company interactively | -| `list-companies` | List all companies with price and share count | -| `add-currency` | Add a new currency interactively | -| `list-currency` | List all currencies with their IDs | -| `add-revenue` | Add a revenue entry interactively | -| `list-revenue` | List revenue entries for a company/period | -| `sum-revenue` | Sum revenue across all periods in a year | -| `help` | Show all available commands | -| `exit` | Quit | - -### Example session - -``` -> add-currency - Code (e.g. DKK): DKK - Name (e.g. Danish Krone): Danish Krone - ✓ Currency 'Danish Krone' (DKK) added with ID 1 - -> add-company - Name: Novo Nordisk - Shares outstanding: 4442064180 - Price: 251.00 - Currency ID: 1 - ✓ Company 'Novo Nordisk' added. - -> add-revenue - Company ID: 1 - Currency ID: 1 - Period type (Q/H/Y): Q - Year: 2025 - Index (Q: 1-4 | H: 1-2 | Y: 1): 1 - Category (product/location/total): product - Label (e.g. iPhone, Americas): Diabetes Care - Value: 54200 - ✓ Revenue entry added: product / Diabetes Care = 54200.00 (Q1 2025) - -> sum-revenue - Company ID: 1 - Period type to sum (Q/H/Y): Q - Year: 2025 - - Revenue Sum — FY2025 - Total: 54200.00 - - By Category: - product 54200.00 - - By Label: - Diabetes Care 54200.00 - -> list-companies - ID NAME CURRENCY PRICE SHARES - ------------------------------------------------------------ - 1 Novo Nordisk DKK 251.00 4442064180 -``` +| Command | Description | +|------------------|--------------------------------------------------| +| `add-company` | Add a new company interactively | +| `list-companies` | List all companies with price and share count | +| `add-currency` | Add a new currency interactively | +| `list-currency` | List all currencies with their IDs | +| `add-revenue` | Add a revenue entry interactively | +| `list-revenue` | List revenue entries for a company/period | +| `sum-revenue` | Sum revenue across all periods in a year | +| `help` | Show all available commands | +| `exit` | Quit | --- @@ -313,20 +330,15 @@ CREATE TABLE revenue_entries ( FOREIGN KEY (currency_id) REFERENCES currencies(id) ); -CREATE TABLE category_defs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - company_id INTEGER NOT NULL, - name TEXT NOT NULL, - FOREIGN KEY (company_id) REFERENCES companies(id), - UNIQUE(company_id, name) -); - -CREATE TABLE category_labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category_def_id INTEGER NOT NULL, - label TEXT NOT NULL, - FOREIGN KEY (category_def_id) REFERENCES category_defs(id), - UNIQUE(category_def_id, label) +CREATE TABLE trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + currency_id INTEGER NOT NULL, + shares INTEGER NOT NULL, + product INTEGER NOT NULL CHECK(product IN (0, 1, 2, 3)), + type INTEGER NOT NULL CHECK(type IN (0, 1)), + price REAL NOT NULL, + traded_at DATETIME NOT NULL ); ``` @@ -334,8 +346,9 @@ CREATE TABLE category_labels ( ## Roadmap -- `GET /company/{id}/revenue` — full revenue history for a company -- Price update endpoint -- Market cap calculation (price × shares outstanding) -- Multi-currency conversion -- Frontend dashboard \ No newline at end of file +* `GET /company/{id}/revenue` — full revenue history for a company +* `GET /trade/list?ticker_id=1` — filter trades by company +* Price update endpoint +* Market cap calculation (price × shares outstanding) +* Multi-currency conversion +* Frontend dashboard \ No newline at end of file diff --git a/app.db b/app.db index ce7c245..a0c9090 100644 Binary files a/app.db and b/app.db differ diff --git a/internal/database/main.go b/internal/database/main.go index c32598d..8805e0d 100644 --- a/internal/database/main.go +++ b/internal/database/main.go @@ -16,6 +16,25 @@ func InitDB(db *sql.DB) { name TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + currency_id INTEGER NOT NULL, + shares INTEGER NOT NULL, + product INTEGER NOT NULL CHECK(product IN (0, 1, 2, 3)), + type INTEGER NOT NULL CHECK(type IN (0, 1)), + price REAL NOT NULL + traded_at DATETIME NOT NULL + ); + + CREATE TABLE IF NOT EXISTS position ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + shares INTEGER NOT NULL, + weight REAL NOT NULL, + CostBases REAL NOT NULL, + ); + CREATE TABLE IF NOT EXISTS companies ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, diff --git a/internal/database/portfolio.go b/internal/database/portfolio.go new file mode 100644 index 0000000..0703a8e --- /dev/null +++ b/internal/database/portfolio.go @@ -0,0 +1,45 @@ +package database + +import ( + "Portifolio/internal/model" + "database/sql" + + _ "github.com/mattn/go-sqlite3" +) + +func GetTrades(db *sql.DB) ([]model.Trade, error) { + rows, err := db.Query("SELECT id, company_id, currency_id, shares, product, type, price, traded_at FROM trades") + if err != nil { + return nil, err + } + defer rows.Close() + + var trades []model.Trade + for rows.Next() { + var t model.Trade + err := rows.Scan(&t.Ticker, &t.Currency, &t.Shares, &t.Product, &t.Type, &t.Price, &t.Date) + if err != nil { + return nil, err + } + trades = append(trades, t) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return trades, nil +} + +func InsertTrade(db *sql.DB, trade model.Trade) error { + _, err := db.Exec( + "INSERT INTO trades (company_id, currency_id, shares, product, type, price, traded_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + trade.Ticker.ID, + trade.Currency.ID, + trade.Shares, + trade.Product, + trade.Type, + trade.Price, + trade.Date, + ) + return err +} diff --git a/internal/handlers/portfolio.go b/internal/handlers/portfolio.go new file mode 100644 index 0000000..871943f --- /dev/null +++ b/internal/handlers/portfolio.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "Portifolio/internal/database" + "Portifolio/internal/model" + "database/sql" + "encoding/json" + "net/http" + + _ "github.com/mattn/go-sqlite3" +) + +func AddTradeHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req model.AddTradeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + err := req.Validate() + if err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + } +} + +func GetTradeListHandler(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tradeList, err := database.GetTrades(db) + if err != nil { + http.Error(w, "failed to fetch trades", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(tradeList); err != nil { + http.Error(w, "failed to encode trades", http.StatusInternalServerError) + return + } + } +} diff --git a/internal/model/portifolio.go b/internal/model/portifolio.go new file mode 100644 index 0000000..dcb3b5b --- /dev/null +++ b/internal/model/portifolio.go @@ -0,0 +1,81 @@ +package model + +import ( + "errors" + "time" +) + +type Position struct { + Company Company + weight float64 + CostBasis float64 + Shares int +} + +type TradeProduct int + +const ( + StockTrade TradeProduct = iota // 0 + OptionCallTrade // 1 + OptionPutTrade // 2 + CurrencyTrade // 3 + BondTrade +) + +type TradeType bool + +const ( + Buy TradeType = true + Sell TradeType = false +) + +type Trade struct { + Ticker Company + Shares int + Product TradeProduct + Type TradeType + Price float64 + Currency Currency + Date time.Time +} + +type AddTradeRequest struct { + TickerId int + Shares int + Product int + Type bool + Price float64 + Currency string + Date time.Time +} + +func (r *AddTradeRequest) Validate() error { + if r.TickerId <= 0 { + return errors.New("ticker id must be a positive integer") + } + if r.Shares <= 0 { + return errors.New("shares must be a positive integer") + } + if r.Product < 0 || r.Product > 3 { + return errors.New("product must be between 0 and 3") + } + if r.Price <= 0 { + return errors.New("price must be a positive number") + } + if r.Currency == "" { + return errors.New("currency is required") + } + if r.Date.IsZero() { + return errors.New("date is required") + } + if r.Date.After(time.Now()) { + return errors.New("date cannot be in the future") + } + return nil +} + +// for now trades and none stock position will not be supported. +type Portifolio struct { + Positions []Position + Trades []Trade +} diff --git a/main.go b/main.go index 10624bd..3f34dbf 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,10 @@ func main() { http.HandleFunc("/health", handlers.HealthHandler(db)) + //Trades + http.HandleFunc("POST /trade/add", handlers.AddTradeHandler(db)) + http.HandleFunc("GET /trade/list", handlers.GetTradeListHandler(db)) + // Company http.HandleFunc("POST /add/company", handlers.AddCompanyHandler(db)) http.HandleFunc("GET /companies", handlers.GetCompaniesHandler(db))