new endpoints

This commit is contained in:
zipfriis
2026-03-25 16:54:46 +01:00
parent 4e5b830e75
commit 52d99c7012
8 changed files with 305 additions and 99 deletions

Binary file not shown.

187
README.md
View File

@@ -1,15 +1,16 @@
# Portfolio Engine # 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 ## Features
- SQLite database with foreign key enforcement * SQLite database with foreign key enforcement
- REST API on port `8080` * REST API on port `8080`
- Interactive shell for managing data without an HTTP client * Interactive shell for managing data without an HTTP client
- Revenue tracking by period (quarterly, half-year, full year) * Revenue tracking by period (quarterly, half-year, full year)
- Revenue broken down by custom categories (product, location, segment...) * Revenue broken down by custom categories (product, location, segment...)
- Health endpoint with DB latency, memory, and uptime stats * Trade tracking (stocks, options, currency trades)
* Health endpoint with DB latency, memory, and uptime stats
## Project Structure ## Project Structure
@@ -25,12 +26,14 @@ A lightweight portfolio and financial data backend written in Go. Tracks compani
├── handlers/ ├── handlers/
│ ├── main.go # HealthHandler │ ├── main.go # HealthHandler
│ ├── currency.go # AddCurrencyHandler, GetCurrenciesHandler │ ├── currency.go # AddCurrencyHandler, GetCurrenciesHandler
── revenue.go # AddRevenueEntryHandler, GetRevenueReportHandler, GetRevenueSumHandler ── revenue.go # AddRevenueEntryHandler, GetRevenueReportHandler, GetRevenueSumHandler
│ └── trade.go # AddTradeHandler, GetTradeListHandler
├── model/ ├── model/
│ ├── company.go # Company struct + SQL (InsertCompany, GetAllCompanies) │ ├── company.go # Company struct + SQL (InsertCompany, GetAllCompanies)
│ ├── currency.go # Currency struct + SQL (InsertCurrency, GetAllCurrencies) │ ├── currency.go # Currency struct + SQL (InsertCurrency, GetAllCurrencies)
│ ├── periode.go # Period struct + helpers (QuarterPeriod, HalfYearPeriod, FullYearPeriod) │ ├── 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/ ├── service/
│ ├── main.go # Service wiring │ ├── main.go # Service wiring
│ ├── company.go # Company business logic │ ├── company.go # Company business logic
@@ -46,14 +49,14 @@ A lightweight portfolio and financial data backend written in Go. Tracks compani
### Prerequisites ### Prerequisites
- Go 1.21+ * Go 1.21+
- GCC (required for SQLite CGO bindings) * GCC (required for SQLite CGO bindings)
- Ubuntu/Debian: `sudo apt install gcc` + Ubuntu/Debian: `sudo apt install gcc`
- macOS: comes with Xcode Command Line Tools + macOS: comes with Xcode Command Line Tools
### Install & Run ### Install & Run
```bash ```
git clone git@git.samantha42.xyz:samantha/Portifolio-Engine.git git clone git@git.samantha42.xyz:samantha/Portifolio-Engine.git
cd Portifolio-Engine cd Portifolio-Engine
go mod download go mod download
@@ -62,7 +65,7 @@ go run main.go
Or build first: Or build first:
```bash ```
chmod +x build.sh chmod +x build.sh
./build.sh ./build.sh
./Portifolio ./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. Returns server status, DB connection info, memory usage, and uptime.
```bash ```
curl http://localhost:8080/health curl http://localhost:8080/health
``` ```
@@ -120,7 +123,7 @@ Returns `503` with `"status": "degraded"` if the database is unreachable.
### `POST /add/company` ### `POST /add/company`
```bash ```
curl -X POST http://localhost:8080/add/company \ curl -X POST http://localhost:8080/add/company \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"name":"Novo Nordisk","shares_outstanding":4442064180,"price":251.00,"currency_id":1}' -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` ### `GET /companies`
```bash ```
curl http://localhost:8080/companies curl http://localhost:8080/companies
``` ```
@@ -138,7 +141,7 @@ curl http://localhost:8080/companies
### `POST /add/currency` ### `POST /add/currency`
```bash ```
curl -X POST http://localhost:8080/add/currency \ curl -X POST http://localhost:8080/add/currency \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"code":"DKK","name":"Danish Krone"}' -d '{"code":"DKK","name":"Danish Krone"}'
@@ -148,17 +151,77 @@ curl -X POST http://localhost:8080/add/currency \
### `GET /currencies` ### `GET /currencies`
```bash ```
curl http://localhost:8080/currencies 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` ### `POST /add/revenue/entry`
Add a single revenue line to a report. The period is created automatically if it doesn't exist. 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 \ curl -X POST http://localhost:8080/add/revenue/entry \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
@@ -187,7 +250,7 @@ curl -X POST http://localhost:8080/add/revenue/entry \
Get all revenue entries for a company and period. 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" 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. 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" curl "http://localhost:8080/revenue/sum?company_id=1&period_type=Q&year=2025"
``` ```
@@ -206,7 +269,7 @@ curl "http://localhost:8080/revenue/sum?company_id=1&period_type=Q&year=2025"
## Shell Commands ## Shell Commands
| Command | Description | | Command | Description |
|------------------|-----------------------------------------------| |------------------|--------------------------------------------------|
| `add-company` | Add a new company interactively | | `add-company` | Add a new company interactively |
| `list-companies` | List all companies with price and share count | | `list-companies` | List all companies with price and share count |
| `add-currency` | Add a new currency interactively | | `add-currency` | Add a new currency interactively |
@@ -217,52 +280,6 @@ curl "http://localhost:8080/revenue/sum?company_id=1&period_type=Q&year=2025"
| `help` | Show all available commands | | `help` | Show all available commands |
| `exit` | Quit | | `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
```
--- ---
## Database Schema ## Database Schema
@@ -313,20 +330,15 @@ CREATE TABLE revenue_entries (
FOREIGN KEY (currency_id) REFERENCES currencies(id) FOREIGN KEY (currency_id) REFERENCES currencies(id)
); );
CREATE TABLE category_defs ( CREATE TABLE trades (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL, company_id INTEGER NOT NULL,
name TEXT NOT NULL, currency_id INTEGER NOT NULL,
FOREIGN KEY (company_id) REFERENCES companies(id), shares INTEGER NOT NULL,
UNIQUE(company_id, name) product INTEGER NOT NULL CHECK(product IN (0, 1, 2, 3)),
); type INTEGER NOT NULL CHECK(type IN (0, 1)),
price REAL NOT NULL,
CREATE TABLE category_labels ( traded_at DATETIME NOT NULL
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)
); );
``` ```
@@ -334,8 +346,9 @@ CREATE TABLE category_labels (
## Roadmap ## Roadmap
- `GET /company/{id}/revenue` — full revenue history for a company * `GET /company/{id}/revenue` — full revenue history for a company
- Price update endpoint * `GET /trade/list?ticker_id=1` — filter trades by company
- Market cap calculation (price × shares outstanding) * Price update endpoint
- Multi-currency conversion * Market cap calculation (price × shares outstanding)
- Frontend dashboard * Multi-currency conversion
* Frontend dashboard

BIN
app.db

Binary file not shown.

View File

@@ -16,6 +16,25 @@ func InitDB(db *sql.DB) {
name TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}

View File

@@ -35,6 +35,10 @@ func main() {
http.HandleFunc("/health", handlers.HealthHandler(db)) http.HandleFunc("/health", handlers.HealthHandler(db))
//Trades
http.HandleFunc("POST /trade/add", handlers.AddTradeHandler(db))
http.HandleFunc("GET /trade/list", handlers.GetTradeListHandler(db))
// Company // Company
http.HandleFunc("POST /add/company", handlers.AddCompanyHandler(db)) http.HandleFunc("POST /add/company", handlers.AddCompanyHandler(db))
http.HandleFunc("GET /companies", handlers.GetCompaniesHandler(db)) http.HandleFunc("GET /companies", handlers.GetCompaniesHandler(db))