Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backend Copy Portfolio Routes #44

Merged
merged 16 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/brianvoe/gofakeit/v6 v6.19.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
Expand Down Expand Up @@ -38,7 +35,6 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
Expand All @@ -56,8 +52,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
Expand Down Expand Up @@ -96,8 +90,6 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
Expand Down Expand Up @@ -177,8 +169,6 @@ go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
Expand Down Expand Up @@ -207,7 +197,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
1 change: 1 addition & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func main() {
routes.SetupPostRoutes(r, db)
routes.SetupOnboardingRoutes(r, db)
routes.SetupFollowingRoutes(r, db)
routes.SetupPortfolioRoutes(r, db)

r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

Expand Down
87 changes: 87 additions & 0 deletions backend/src/controllers/portfolio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package controllers

import (
"backend/src/services"
"net/http"

// "fmt"

"github.com/gin-gonic/gin"

"backend/src/models"
)

type PortfolioController struct {
portfolioService *services.PortfolioService
etradeService *services.ETradeService
}

func NewPortfolioController(portfolioService *services.PortfolioService, etradeService *services.ETradeService) *PortfolioController {
return &PortfolioController{
portfolioService: portfolioService,
etradeService: etradeService,
}
}

// CopyPortfolio copies the target user's portfolio to the current user's portfolio
func (etc *PortfolioController) CopyPortfolio(c *gin.Context) {
currentUserID := c.Query("current_user_id")
targetUserID := c.Query("target_user_id")

// get current user's portfolio. If it doesn't exist, create empty portfolio
currentUserPortfolio, err := etc.portfolioService.GetUserPortfolio(currentUserID)
if err != nil {
// create empty portfolio for the user
emptyPortfolio := &models.UserPortfolio{}
newPortfolio, createErr := etc.portfolioService.CreateUserPortfolio(currentUserID, emptyPortfolio)
currentUserPortfolio = newPortfolio
if createErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create empty portfolio", "errorMessage": createErr.Error()})
return
}
}

// get target user's portfolio. If it doesn't exist, return error
targetPortfolio, err := etc.portfolioService.GetUserPortfolio(targetUserID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Target User does not have portfolio to copy", "errorMessage": err.Error()})
return
}

// if target user has no positions, return error
if len(targetPortfolio.Positions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Target User's Portfolio has no positions to copy"})
return
}

// copy positions to current user's portfolio
_, copyErr := etc.portfolioService.CopyPortfolio(*currentUserPortfolio, *targetPortfolio)
if copyErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy positions", "errorMessage": copyErr.Error()})
return
}

// retrieve portfolio so db has time to register new positions and index their ids
updatedPortfolio, retrieveErr := etc.portfolioService.GetUserPortfolio(currentUserID)
if retrieveErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated portfolio", "errorMessage": retrieveErr.Error()})
return
}

c.JSON(http.StatusOK, updatedPortfolio)
// c.JSON(http.StatusOK, copiedPortfolio)
}

// GetUserPortfolio returns the user's portfolio
func (etc *PortfolioController) GetUserPortfolio(c *gin.Context) {
userID := c.Param("user_id")

// Get the user's portfolio
portfolio, err := etc.portfolioService.GetUserPortfolio(userID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "User does not have portfolio"})
return
}

c.JSON(http.StatusOK, portfolio)
}
10 changes: 9 additions & 1 deletion backend/src/db/migrations/8_POSITIONS_V1.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@ CREATE TABLE positions
total_gain NUMERIC(12, 2) NOT NULL,
total_gain_pct NUMERIC(12, 2) NOT NULL,
type trade_type_enum NOT NULL
);
);

-- Insert dummy data into positions table
INSERT INTO positions (user_portfolio_id, position_id, ticker, quantity, cost, day_gain, day_gain_pct, total_gain, total_gain_pct, type)
VALUES
(1, 123456, 'AAPL', 10, 150.25, 20.50, 2.5, 100.75, 5.25, 'LONG'),
(1, 654321, 'GOOGL', 5, 250.75, 10.25, 1.75, 50.50, 2.75, 'SHORT'),
(2, 987654, 'MSFT', 8, 300.50, 15.75, 2.25, 80.25, 4.5, 'LONG'),
(3, 246813, 'AMZN', 12, 200.25, 25.50, 3.25, 150.75, 7.75, 'SHORT');
4 changes: 3 additions & 1 deletion backend/src/models/etrade.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package models

import "backend/src/types"
import (
"backend/src/types"
)

type OAuthTokens struct {
types.Model
Expand Down
25 changes: 25 additions & 0 deletions backend/src/routes/portfolio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package routes

import (
"backend/src/controllers"
"backend/src/services"

"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

func SetupPortfolioRoutes(router *gin.Engine, db *gorm.DB) {
portfolioService := services.NewPortfolioService(db)
etradeService := services.NewETradeService(db)
portfolioController := controllers.NewPortfolioController(portfolioService, etradeService)

portfolioRoutes := router.Group("/portfolio")
{
portfolioRoutes.PUT("", portfolioController.CopyPortfolio)
/* different than getportfolio in etrade, returns single object for simplicity
keep etrade getportfolio route to match actual Etrade data
use either call depending on what you need
*/
portfolioRoutes.GET("/:user_id", portfolioController.GetUserPortfolio)
}
}
16 changes: 0 additions & 16 deletions backend/src/routes/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,6 @@ func SetupUserRoutes(router *gin.Engine, db *gorm.DB, clerkClient clerk.Client)

userRoutes := router.Group("/users")
{
/* Protected Routes */

/*
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
Clerk middleware currently doesn't work since ngrok does not allow us to
edit request headers.

We can:
- implement our own clerk middleware
- find out how to modify request headers with axios and bypass ngrok
- find out how to modify request headers with ngrok options dynamically
- add middleware to filter all requests and attach headers once they reach the backend (might conflict with clerk)

*/
// SetupAuthMiddleware(clerkClient, router)

// Routes that only read/write DB
userRoutes.GET("", userController.GetAllUsers)
userRoutes.POST("", userController.CreateUser)
Expand Down
72 changes: 72 additions & 0 deletions backend/src/services/etrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"

Expand Down Expand Up @@ -336,6 +337,77 @@ func (s *ETradeService) GetUserPortfolio(userID string) (models.UserPortfolio, e
return portfolio, nil
}

// PlaceOrder places an order on the E*Trade API
func (s *ETradeService) PlaceOrder(userID string, order types.Order) error {
oauthTokens, err := s.getLastOAuthTokens(userID)
if err != nil {
return fmt.Errorf("error getting oauth token: %s", err)
}

client := newJSONClient()

// Create the required order details structure
orderDetails := types.PreviewOrderRequest{
ClientOrderID: fmt.Sprintf("%d", time.Now().UnixMilli()), // Use unique client order ID
Order: []types.OrderEntry{
{
Instrument: []types.Instrument{
{
Product: types.Product{
SecurityType: order.SecurityType, // "EQ" for stocks, "OPTION" for options, etc.
Symbol: order.Symbol,
},
OrderAction: order.Action, // "BUY", "SELL", etc.
QuantityType: types.QuantityType(order.QuantityType), // "QUANTITY", "DOLLARS", etc.
Quantity: order.Quantity,
},
},
OrderTerm: order.OrderTerm, // "GOOD_FOR_DAY", "IMMEDIATE_OR_CANCEL", etc.
MarketSession: order.MarketSession, // "REGULAR", "EXTENDED_HOURS"
PriceType: order.PriceType, // "MARKET", "LIMIT", etc.
StopPrice: order.StopPrice, // Used for stop orders
LimitPrice: order.LimitPrice, // Used for limit orders
AllOrNone: order.AllOrNone, // Boolean
},
},
}

// Serialize orderDetails into JSON
orderData, err := json.Marshal(orderDetails)
if err != nil {
return fmt.Errorf("error serializing order details: %s", err)
}

// Calculate the URL based on order preview vs. actual placement
baseURL := fmt.Sprintf("https://%s.etrade.com/v1/accounts/%s/orders/place", APIEnv, order.AccountID)

// Make the API call using the OAuth credentials
orderValues, err := url.ParseQuery(string(orderData))
if err != nil {
return fmt.Errorf("error parsing order data: %s", err)
}

resp, err := oauthClient.Post(client, &oauth.Credentials{
Token: oauthTokens.AccessToken,
Secret: oauthTokens.AccessSecret,
}, baseURL, orderValues)
if err != nil {
return fmt.Errorf("error sending order to E*Trade: %s", err)
}
defer resp.Body.Close()

// Handle potential API errors
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("E*Trade API error: %s", body)
}

// On success, you can extract the order ID from the response for confirmation
// or further tracking.

return nil
}

// newJSONClient is a helper function that creates an HTTP client to interact with the E*Trade API
// not using this causes the E*Trade API to return XML instead of JSON
func newJSONClient() *http.Client {
Expand Down
Loading
Loading