@marcos_feitoza/personal-finance-backen-trades-assets 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.circleci/config-old.txt +67 -0
- package/.circleci/config.yml +23 -0
- package/CHANGELOG.md +12 -0
- package/README.md +22 -0
- package/package.json +29 -0
- package/personal_finance_trades_assets/main.py +20 -0
- package/personal_finance_trades_assets/routers/assets.py +45 -0
- package/personal_finance_trades_assets/routers/trades.py +114 -0
- package/requirements.txt +6 -0
- package/setup.py +11 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
version: 2.1
|
|
2
|
+
|
|
3
|
+
jobs:
|
|
4
|
+
build-and-push:
|
|
5
|
+
machine: true
|
|
6
|
+
steps:
|
|
7
|
+
- checkout
|
|
8
|
+
- run:
|
|
9
|
+
name: Check for PR or main branch
|
|
10
|
+
command: |
|
|
11
|
+
if [ -z "${CIRCLE_PULL_REQUEST}" ] && [ "${CIRCLE_BRANCH}" != "main" ]; then
|
|
12
|
+
echo "Not a PR and not main. Halting job."
|
|
13
|
+
circleci-agent step halt
|
|
14
|
+
fi
|
|
15
|
+
- run:
|
|
16
|
+
name: Install buildx and QEMU for multi-arch builds
|
|
17
|
+
command: |
|
|
18
|
+
sudo apt-get update
|
|
19
|
+
sudo apt-get install -y qemu-user-static
|
|
20
|
+
docker run --privileged --rm tonistiigi/binfmt --install all
|
|
21
|
+
docker buildx create --name mybuilder --use
|
|
22
|
+
docker buildx inspect --bootstrap
|
|
23
|
+
- run:
|
|
24
|
+
name: Login to Docker Hub
|
|
25
|
+
command: |
|
|
26
|
+
echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin
|
|
27
|
+
- run:
|
|
28
|
+
name: Build and push frontend image
|
|
29
|
+
command: |
|
|
30
|
+
COMMIT_SHA=${CIRCLE_SHA1:0:7}
|
|
31
|
+
IMAGE_TAG="$DOCKER_REPO/$CIRCLE_PROJECT_REPONAME:$COMMIT_SHA"
|
|
32
|
+
|
|
33
|
+
echo "Building image for frontend"
|
|
34
|
+
echo "Tag: $IMAGE_TAG"
|
|
35
|
+
|
|
36
|
+
docker buildx build --platform=linux/amd64,linux/arm64,linux/arm/v7 \
|
|
37
|
+
--tag $IMAGE_TAG \
|
|
38
|
+
--push .
|
|
39
|
+
|
|
40
|
+
release:
|
|
41
|
+
docker:
|
|
42
|
+
- image: circleci/node:latest
|
|
43
|
+
steps:
|
|
44
|
+
- checkout
|
|
45
|
+
- run:
|
|
46
|
+
name: Install dependencies
|
|
47
|
+
command: npm install
|
|
48
|
+
- run:
|
|
49
|
+
name: Run semantic-release
|
|
50
|
+
command: npx semantic-release
|
|
51
|
+
|
|
52
|
+
workflows:
|
|
53
|
+
build-and-release:
|
|
54
|
+
jobs:
|
|
55
|
+
- build-and-push:
|
|
56
|
+
filters:
|
|
57
|
+
branches:
|
|
58
|
+
only:
|
|
59
|
+
- main
|
|
60
|
+
- /.*/ # permite todas as branches, mas o job se auto-interrompe se não for PR
|
|
61
|
+
- release:
|
|
62
|
+
requires:
|
|
63
|
+
- build-and-push
|
|
64
|
+
filters:
|
|
65
|
+
branches:
|
|
66
|
+
only:
|
|
67
|
+
- main
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
version: 2.1
|
|
2
|
+
|
|
3
|
+
jobs:
|
|
4
|
+
release:
|
|
5
|
+
docker:
|
|
6
|
+
- image: circleci/node:latest
|
|
7
|
+
steps:
|
|
8
|
+
- checkout
|
|
9
|
+
- run:
|
|
10
|
+
name: Install dependencies
|
|
11
|
+
command: npm install
|
|
12
|
+
- run:
|
|
13
|
+
name: Run semantic-release
|
|
14
|
+
command: npx semantic-release
|
|
15
|
+
|
|
16
|
+
workflows:
|
|
17
|
+
build-and-release:
|
|
18
|
+
jobs:
|
|
19
|
+
- release:
|
|
20
|
+
filters:
|
|
21
|
+
branches:
|
|
22
|
+
only:
|
|
23
|
+
- main
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 1.0.0 (2025-09-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* remove files que nao usaremos ([d5ab4eb](https://github.com/MarcosOps/personal-finance-backend-trades-assets/commit/d5ab4ebcb540472b46a6021230855c34587ffa63))
|
|
7
|
+
* update docker (recovery point) ([fe0b8d5](https://github.com/MarcosOps/personal-finance-backend-trades-assets/commit/fe0b8d510c5f252066b996ade940203aa87575f1))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
|
|
12
|
+
* general fix ([cf1a13a](https://github.com/MarcosOps/personal-finance-backend-trades-assets/commit/cf1a13a42740bbb49e26ae3eb85d9c8fde158581))
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Biblioteca de Trades e Ativos - Personal Finance
|
|
2
|
+
|
|
3
|
+
Este projeto contém a lógica de negócios específica para o gerenciamento de `Trades` (transações de compra/venda) e `Assets` (ativos como ações, ETFs, etc.).
|
|
4
|
+
|
|
5
|
+
## Propósito
|
|
6
|
+
|
|
7
|
+
Esta biblioteca encapsula as regras para criar e validar trades. Sua principal responsabilidade é, ao receber um novo trade para um símbolo de ação que ainda não existe no banco de dados, buscar os detalhes desse ativo (nome, setor, etc.) usando uma API externa e salvá-lo corretamente.
|
|
8
|
+
|
|
9
|
+
## Tecnologias
|
|
10
|
+
|
|
11
|
+
- **Busca de Dados:** Usa a biblioteca `yfinance` para buscar os detalhes de novos ativos.
|
|
12
|
+
- **Banco de Dados:** Interage com o banco de dados através da `personal-finance-backend-shared`.
|
|
13
|
+
|
|
14
|
+
## Como Usar
|
|
15
|
+
|
|
16
|
+
Este projeto não é um serviço executável. Ele é projetado para ser instalado como uma dependência local pelo serviço principal (`core-transactions-service`).
|
|
17
|
+
|
|
18
|
+
No `requirements.txt` do serviço principal, adicione a seguinte linha para incluí-lo:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
-e ./trades-assets-library
|
|
22
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marcos_feitoza/personal-finance-backen-trades-assets",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"scripts": {
|
|
8
|
+
"release": "semantic-release"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"semantic-release": "^18.0.0",
|
|
12
|
+
"@semantic-release/changelog": "^6.0.0",
|
|
13
|
+
"@semantic-release/git": "^10.0.0",
|
|
14
|
+
"@semantic-release/github": "^8.0.0"
|
|
15
|
+
},
|
|
16
|
+
"release": {
|
|
17
|
+
"branches": [
|
|
18
|
+
"main"
|
|
19
|
+
],
|
|
20
|
+
"plugins": [
|
|
21
|
+
"@semantic-release/commit-analyzer",
|
|
22
|
+
"@semantic-release/release-notes-generator",
|
|
23
|
+
"@semantic-release/changelog",
|
|
24
|
+
"@semantic-release/npm",
|
|
25
|
+
"@semantic-release/github",
|
|
26
|
+
"@semantic-release/git"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
3
|
+
from .routers import trades, assets
|
|
4
|
+
|
|
5
|
+
app = FastAPI()
|
|
6
|
+
|
|
7
|
+
app.add_middleware(
|
|
8
|
+
CORSMiddleware,
|
|
9
|
+
allow_origins=["*"], # Allows all origins
|
|
10
|
+
allow_credentials=True,
|
|
11
|
+
allow_methods=["*"], # Allows all methods
|
|
12
|
+
allow_headers=["*"], # Allows all headers
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
app.include_router(trades.router)
|
|
16
|
+
app.include_router(assets.router)
|
|
17
|
+
|
|
18
|
+
@app.get("/api/")
|
|
19
|
+
def read_root():
|
|
20
|
+
return {"message": "Trades and Assets API"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
from sqlalchemy import distinct
|
|
4
|
+
from personal_finance_shared import models, schemas
|
|
5
|
+
from personal_finance_shared.database import get_db
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logging.basicConfig(level=logging.INFO)
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
router = APIRouter(
|
|
12
|
+
prefix="/api/assets",
|
|
13
|
+
tags=["assets"],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
@router.post("/", response_model=schemas.AssetResponse)
|
|
17
|
+
def create_asset(asset: schemas.AssetCreate, db: Session = Depends(get_db)):
|
|
18
|
+
db_asset = db.query(models.Asset).filter(models.Asset.symbol == asset.symbol).first()
|
|
19
|
+
if db_asset:
|
|
20
|
+
raise HTTPException(status_code=400, detail="Asset with this symbol already exists")
|
|
21
|
+
db_asset = models.Asset(**asset.dict())
|
|
22
|
+
db.add(db_asset)
|
|
23
|
+
db.commit()
|
|
24
|
+
db.refresh(db_asset)
|
|
25
|
+
return db_asset
|
|
26
|
+
|
|
27
|
+
@router.get("/", response_model=list[schemas.AssetResponse])
|
|
28
|
+
def read_assets(investment_account: str = None, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
|
29
|
+
logger.info(f"Reading assets for investment account: {investment_account}")
|
|
30
|
+
query = db.query(models.Asset)
|
|
31
|
+
|
|
32
|
+
if investment_account:
|
|
33
|
+
# Get asset_ids from trades for the given investment_account
|
|
34
|
+
asset_ids_in_account = db.query(distinct(models.Trade.asset_id)).filter(
|
|
35
|
+
models.Trade.investment_account == investment_account
|
|
36
|
+
).all()
|
|
37
|
+
# Extract just the IDs from the list of tuples
|
|
38
|
+
asset_ids = [id for (id,) in asset_ids_in_account]
|
|
39
|
+
|
|
40
|
+
# Filter assets by these IDs
|
|
41
|
+
query = query.filter(models.Asset.id.in_(asset_ids))
|
|
42
|
+
|
|
43
|
+
assets = query.offset(skip).limit(limit).all()
|
|
44
|
+
logger.info(f"Found {len(assets)} assets")
|
|
45
|
+
return assets
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
from personal_finance_shared import models, schemas, crud
|
|
4
|
+
from personal_finance_shared.database import get_db
|
|
5
|
+
import logging
|
|
6
|
+
import requests
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(level=logging.INFO)
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
router = APIRouter(
|
|
13
|
+
prefix="/api/trades",
|
|
14
|
+
tags=["trades"],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
MARKET_DATA_SERVICE_URL = os.getenv("MARKET_DATA_SERVICE_URL", "http://personal-finance-backend-market-data:8000")
|
|
18
|
+
|
|
19
|
+
def _get_asset_details_from_service(symbol: str) -> dict:
|
|
20
|
+
"""Fetches asset details from the market-data service."""
|
|
21
|
+
try:
|
|
22
|
+
url = f"{MARKET_DATA_SERVICE_URL}/api/market-data/details/{symbol.upper()}"
|
|
23
|
+
logger.info(f"Fetching details from: {url}")
|
|
24
|
+
response = requests.post(url, timeout=10) # Using POST as per the new endpoint
|
|
25
|
+
response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
|
|
26
|
+
return response.json()
|
|
27
|
+
except requests.exceptions.RequestException as e:
|
|
28
|
+
logger.error(f"Error calling market-data service for {symbol}: {e}")
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/", response_model=schemas.TradeResponse)
|
|
34
|
+
def create_trade(trade: schemas.TradeCreate, db: Session = Depends(get_db)):
|
|
35
|
+
trade_symbol = trade.symbol.upper()
|
|
36
|
+
logger.info(f"Processing trade for symbol: {trade_symbol}")
|
|
37
|
+
|
|
38
|
+
# Check if asset exists with the provided symbol or its canonical variations
|
|
39
|
+
db_asset = db.query(models.Asset).filter(models.Asset.symbol == trade_symbol).first()
|
|
40
|
+
if not db_asset and '.' not in trade_symbol:
|
|
41
|
+
db_asset = db.query(models.Asset).filter(models.Asset.symbol == f"{trade_symbol}.TO").first()
|
|
42
|
+
|
|
43
|
+
if not db_asset:
|
|
44
|
+
logger.info(f"Asset with symbol {trade_symbol} not found. Fetching details...")
|
|
45
|
+
|
|
46
|
+
asset_details = _get_asset_details_from_service(trade_symbol)
|
|
47
|
+
canonical_symbol = asset_details.get("canonical_symbol", trade_symbol)
|
|
48
|
+
|
|
49
|
+
# Double-check if an asset with the canonical symbol already exists
|
|
50
|
+
db_asset = db.query(models.Asset).filter(models.Asset.symbol == canonical_symbol).first()
|
|
51
|
+
|
|
52
|
+
if not db_asset:
|
|
53
|
+
logger.info(f"Creating new asset with canonical symbol: {canonical_symbol}")
|
|
54
|
+
db_asset = models.Asset(
|
|
55
|
+
symbol=canonical_symbol,
|
|
56
|
+
name=asset_details.get("name", canonical_symbol),
|
|
57
|
+
asset_type=asset_details.get("asset_type", "Unknown"),
|
|
58
|
+
industry=asset_details.get("industry", "N/A")
|
|
59
|
+
)
|
|
60
|
+
db.add(db_asset)
|
|
61
|
+
db.commit()
|
|
62
|
+
db.refresh(db_asset)
|
|
63
|
+
logger.info(f"Created new asset with ID: {db_asset.id}")
|
|
64
|
+
else:
|
|
65
|
+
logger.info(f"Found existing asset with canonical symbol: {canonical_symbol}")
|
|
66
|
+
else:
|
|
67
|
+
logger.info(f"Found existing asset with ID: {db_asset.id}")
|
|
68
|
+
|
|
69
|
+
# --- Balance Validation for 'buy' trades ---
|
|
70
|
+
if trade.trade_type == 'buy':
|
|
71
|
+
investment_account_name = trade.investment_account
|
|
72
|
+
trade_cost = trade.shares * trade.price
|
|
73
|
+
|
|
74
|
+
current_balance = crud.get_investment_account_balance(db=db, account_name=investment_account_name)
|
|
75
|
+
logger.info(f"Calculated cash balance for '{investment_account_name}' is {current_balance}")
|
|
76
|
+
|
|
77
|
+
if current_balance < trade_cost:
|
|
78
|
+
logger.warning(f"Insufficient funds in '{investment_account_name}'. Balance: {current_balance}, Required: {trade_cost}")
|
|
79
|
+
raise HTTPException(
|
|
80
|
+
status_code=400,
|
|
81
|
+
detail=f"Insufficient cash balance in '{investment_account_name}' to complete trade."
|
|
82
|
+
)
|
|
83
|
+
# --- End Validation Logic ---
|
|
84
|
+
|
|
85
|
+
# Create the trade record, linking it to the asset via asset_id
|
|
86
|
+
trade_data = trade.dict(exclude={'symbol', 'name', 'asset_type', 'industry'})
|
|
87
|
+
db_trade = models.Trade(**trade_data, asset_id=db_asset.id)
|
|
88
|
+
|
|
89
|
+
# Log for stock purchase/sale
|
|
90
|
+
if db_trade.trade_type == 'buy':
|
|
91
|
+
logger.info(f"STOCK PURCHASE: Bought {db_trade.shares} shares of {trade_symbol} at ${db_trade.price:.2f} each.")
|
|
92
|
+
elif db_trade.trade_type == 'sell':
|
|
93
|
+
logger.info(f"STOCK SALE: Sold {db_trade.shares} shares of {trade_symbol} at ${db_trade.price:.2f} each.")
|
|
94
|
+
|
|
95
|
+
db.add(db_trade)
|
|
96
|
+
db.commit()
|
|
97
|
+
db.refresh(db_trade)
|
|
98
|
+
return db_trade
|
|
99
|
+
|
|
100
|
+
@router.get("/", response_model=list[schemas.TradeResponse])
|
|
101
|
+
def read_trades(
|
|
102
|
+
investment_account: str = None,
|
|
103
|
+
skip: int = 0,
|
|
104
|
+
limit: int = 100,
|
|
105
|
+
db: Session = Depends(get_db)
|
|
106
|
+
):
|
|
107
|
+
logger.info(f"Reading trades for account: {investment_account}")
|
|
108
|
+
query = db.query(models.Trade)
|
|
109
|
+
if investment_account:
|
|
110
|
+
query = query.filter(models.Trade.investment_account == investment_account)
|
|
111
|
+
|
|
112
|
+
trades = query.order_by(models.Trade.date.desc()).offset(skip).limit(limit).all()
|
|
113
|
+
logger.info(f"Found {len(trades)} trades")
|
|
114
|
+
return trades
|
package/requirements.txt
ADDED
package/setup.py
ADDED