├── .gitbook.yaml ├── docs ├── troubleshooting │ ├── error-syncing.md │ ├── README.md │ └── migrate-to-v1.md ├── .gitbook │ └── assets │ │ ├── screen-shot-2021-09-20-at-11.03.05-am.png │ │ ├── screen-shot-2021-09-20-at-5.02.15-pm.png │ │ └── screen-shot-2021-10-01-at-10.30.24-am.png ├── developers │ └── setup-a-dev-build │ │ ├── packaging-the-application.md │ │ └── README.md ├── frequently-asked-questions │ ├── metrics.md │ ├── active-deals.md │ └── google-sheets.md ├── SUMMARY.md ├── configuration │ └── initial-setup.md └── README.md ├── .DS_Store ├── assets ├── palette.pdf ├── icons │ ├── icon.ico │ ├── icon.png │ ├── icons.icns │ ├── 256x256.png │ ├── 512x512.png │ └── icon-old.icns ├── icons.iconset │ ├── icon_16x16.png │ ├── icon_32x32.png │ ├── icon_128x128.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ ├── icon_128x128@2x.png │ ├── icon_256x256@2.png │ └── icon_512x512@2x.png └── assets │ ├── backwardClock.svg │ ├── coffee.svg │ ├── tradingview-v2-svgrepo-com.svg │ ├── moon.svg │ ├── pieChart.svg │ ├── activeDeals.svg │ ├── cog.svg │ ├── power.svg │ ├── botmanager.svg │ └── sun.svg ├── src ├── app │ ├── Components │ │ ├── Charts │ │ │ ├── Pie │ │ │ │ └── index.ts │ │ │ ├── Line │ │ │ │ ├── index.ts │ │ │ │ └── Components │ │ │ │ │ └── PairSelector.tsx │ │ │ ├── Area │ │ │ │ └── index.ts │ │ │ ├── Speedometer │ │ │ │ ├── index.ts │ │ │ │ └── MaxRiskPercent.tsx │ │ │ ├── Scatter │ │ │ │ └── index.ts │ │ │ ├── DataCards │ │ │ │ ├── CustomToolTip.ts │ │ │ │ ├── Card.scss │ │ │ │ ├── metrics │ │ │ │ │ ├── ActiveDeals.tsx │ │ │ │ │ ├── EnabledBots.tsx │ │ │ │ │ ├── TotalDeals.tsx │ │ │ │ │ ├── DropCoverage.tsx │ │ │ │ │ ├── TotalBoughtVolume.tsx │ │ │ │ │ ├── MaxDca.tsx │ │ │ │ │ ├── TotalProfit.tsx │ │ │ │ │ ├── AverageDailyProfit.tsx │ │ │ │ │ ├── TotalDayProfit.tsx │ │ │ │ │ ├── TotalUnrealizedProfit.tsx │ │ │ │ │ ├── ActiveDealReserve.tsx │ │ │ │ │ ├── AverageDealHours.tsx │ │ │ │ │ ├── TotalInDeals.tsx │ │ │ │ │ ├── TotalBankRoll.tsx │ │ │ │ │ ├── MaxRiskPercent.tsx │ │ │ │ │ └── TotalRoi.tsx │ │ │ │ ├── Card.tsx │ │ │ │ └── index.ts │ │ │ ├── Bar │ │ │ │ └── index.ts │ │ │ └── formatting.tsx │ │ ├── icons │ │ │ ├── Loading │ │ │ │ ├── Loading.scss │ │ │ │ └── Loading.tsx │ │ │ ├── BackwardClock.tsx │ │ │ ├── Index.ts │ │ │ ├── Coffee.tsx │ │ │ ├── TradingViewLogo.tsx │ │ │ ├── Moon.tsx │ │ │ ├── PieChart.tsx │ │ │ ├── Cog.tsx │ │ │ ├── ActiveDeals.tsx │ │ │ ├── BotPlanner.tsx │ │ │ └── Sun.tsx │ │ ├── Sidebar │ │ │ ├── Components │ │ │ │ ├── index.ts │ │ │ │ ├── SidebarLink.tsx │ │ │ │ └── SidebarNav.tsx │ │ │ ├── svg │ │ │ │ ├── backwardClock.svg │ │ │ │ ├── coffee.svg │ │ │ │ ├── moon.svg │ │ │ │ ├── pieChart.svg │ │ │ │ ├── cog.svg │ │ │ │ ├── botmanager.svg │ │ │ │ └── sun.svg │ │ │ ├── DisplaySwitcher.tsx │ │ │ ├── Sidebar.scss │ │ │ └── Sidebar.tsx │ │ ├── Buttons │ │ │ ├── Index.tsx │ │ │ ├── ToggleRefresh.scss │ │ │ └── UpdateData.tsx │ │ ├── Selectors │ │ │ ├── index.ts │ │ │ └── AccountSelector.tsx │ │ └── DataTable │ │ │ ├── Index.tsx │ │ │ ├── Components │ │ │ ├── index.ts │ │ │ └── OpenIn3Commas.tsx │ │ │ ├── Table.scss │ │ │ └── FormatDeals.tsx │ ├── Pages │ │ ├── DailyStats │ │ │ ├── Components │ │ │ │ ├── KPIs │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── 3Commas │ │ │ │ │ └── type_dailydashboard.ts │ │ │ └── DailyStats.scss │ │ ├── BotPlanner │ │ │ ├── Components │ │ │ │ ├── index.ts │ │ │ │ ├── SaveButton.tsx │ │ │ │ └── Risk.tsx │ │ │ └── BotPlanner.scss │ │ ├── ActiveDeals │ │ │ └── Components │ │ │ │ ├── SubrowTabs │ │ │ │ ├── Index.tsx │ │ │ │ └── Orders.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── EditDeal.scss │ │ │ │ ├── NotificationsSettings.tsx │ │ │ │ └── Subrow.tsx │ │ ├── Stats │ │ │ ├── Type_stats.tsx │ │ │ ├── Views │ │ │ │ ├── Index.ts │ │ │ │ ├── SummaryStatistics.tsx │ │ │ │ └── RiskMonitor.tsx │ │ │ ├── Components │ │ │ │ ├── Index.tsx │ │ │ │ ├── NoData.tsx │ │ │ │ ├── ViewRenderer.tsx │ │ │ │ └── StatFiltersDiv.tsx │ │ │ ├── Stats.tsx │ │ │ └── Stats.scss │ │ ├── Settings │ │ │ ├── Components │ │ │ │ ├── Index.tsx │ │ │ │ ├── FeedbackOrBugButton.tsx │ │ │ │ ├── StartDatePicker.tsx │ │ │ │ └── WriteModeSettings.tsx │ │ │ ├── Redux │ │ │ │ └── settingsSlice.ts │ │ │ └── Settings.scss │ │ ├── Index.ts │ │ └── MainWindow.tsx │ ├── Repositories │ │ ├── Types │ │ │ ├── Binance.ts │ │ │ └── GithubRelease.ts │ │ ├── interfaces │ │ │ ├── Deals.ts │ │ │ ├── Binance.ts │ │ │ ├── General.ts │ │ │ ├── API.ts │ │ │ ├── Database.ts │ │ │ ├── Config.ts │ │ │ ├── Repository.ts │ │ │ └── index.ts │ │ └── Impl │ │ │ ├── Binance.ts │ │ │ ├── electron │ │ │ ├── Base.ts │ │ │ ├── Deals.ts │ │ │ ├── index.ts │ │ │ ├── API.ts │ │ │ ├── Config.ts │ │ │ └── Database.ts │ │ │ └── General.ts │ ├── Features │ │ ├── Profiles │ │ │ ├── Profiles.scss │ │ │ └── Components │ │ │ │ ├── Index.tsx │ │ │ │ └── ProfileNameEditor.tsx │ │ ├── Index.tsx │ │ ├── UpdateBanner │ │ │ ├── UpdateBanner.scss │ │ │ ├── UpdateApiFetch.ts │ │ │ ├── redux │ │ │ │ └── bannerSlice.ts │ │ │ └── UpdateBanner.tsx │ │ ├── CoinPriceHeader │ │ │ ├── BinanceApi.ts │ │ │ └── CoinPriceHeader.scss │ │ ├── 3Commas │ │ │ ├── queryString.ts │ │ │ ├── Type_3Commas.ts │ │ │ ├── DataQueries │ │ │ │ └── accounts.ts │ │ │ └── 3Commas.ts │ │ ├── ToastNotifications │ │ │ └── ToastNotification.tsx │ │ ├── Changelog │ │ │ └── changelogModal.scss │ │ └── LocalStorage │ │ │ └── LocalStorage.ts │ ├── redux │ │ ├── hooks.ts │ │ ├── store.ts │ │ ├── globalFunctions.ts │ │ └── threeCommas │ │ │ └── initialState.ts │ └── app.tsx ├── types │ ├── Date.ts │ ├── Charts.ts │ ├── config.ts │ └── preload.ts ├── index.html ├── main │ ├── Database │ │ └── helper.ts │ ├── Config │ │ └── migrations │ │ │ └── 2.0.0.ts │ ├── precheck.ts │ └── 3Commas │ │ └── types │ │ ├── GridBots.ts │ │ └── Bots.ts ├── utils │ └── number_formatting.ts └── renderer.tsx ├── .gitignore ├── nodemon.json ├── .vscode └── settings.json ├── webpack.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── build.yml ├── webpack.electron.js ├── webpack.preload.js ├── scripts ├── notarize.js └── getReleaseCounts.js ├── FORMULA_DESCRIPTIONS.md └── webpack.react.js /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | root: ./docs/ -------------------------------------------------------------------------------- /docs/troubleshooting/error-syncing.md: -------------------------------------------------------------------------------- 1 | # Error Syncing 2 | 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/.DS_Store -------------------------------------------------------------------------------- /assets/palette.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/palette.pdf -------------------------------------------------------------------------------- /assets/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons/icon.ico -------------------------------------------------------------------------------- /assets/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons/icon.png -------------------------------------------------------------------------------- /assets/icons/icons.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons/icons.icns -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/icons/icon-old.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons/icon-old.icns -------------------------------------------------------------------------------- /assets/icons.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_16x16.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_32x32.png -------------------------------------------------------------------------------- /src/app/Components/Charts/Pie/index.ts: -------------------------------------------------------------------------------- 1 | import BalancePie from "./BalancePie"; 2 | 3 | export { 4 | BalancePie 5 | } -------------------------------------------------------------------------------- /src/app/Pages/DailyStats/Components/KPIs/index.ts: -------------------------------------------------------------------------------- 1 | import RoiMetrics from "./Roi"; 2 | 3 | export { 4 | RoiMetrics 5 | } -------------------------------------------------------------------------------- /assets/icons.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_128x128.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_256x256.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_512x512.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_256x256@2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_256x256@2.png -------------------------------------------------------------------------------- /assets/icons.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/assets/icons.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /src/app/Repositories/Types/Binance.ts: -------------------------------------------------------------------------------- 1 | 2 | export type BinanceTicketPrice = { 3 | "symbol": string, 4 | "price": string 5 | } -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/Deals.ts: -------------------------------------------------------------------------------- 1 | export default interface DealsRepository { 2 | update(profileData: any, deal: any): any; 3 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/Line/index.ts: -------------------------------------------------------------------------------- 1 | import PairPerformanceByDate from "./PairByDay"; 2 | 3 | export { 4 | PairPerformanceByDate 5 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/Area/index.ts: -------------------------------------------------------------------------------- 1 | import SummaryProfitByDay from "./SummaryProfitByDay"; 2 | 3 | export { 4 | SummaryProfitByDay 5 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/Speedometer/index.ts: -------------------------------------------------------------------------------- 1 | import MaxRiskSpeedometer from "./MaxRiskPercent"; 2 | 3 | export { 4 | MaxRiskSpeedometer 5 | }; -------------------------------------------------------------------------------- /src/app/Components/icons/Loading/Loading.scss: -------------------------------------------------------------------------------- 1 | #loadingIcon svg path, 2 | #loadingIcon svg rect{ 3 | fill: var(--color-text-lightbackground) 4 | } -------------------------------------------------------------------------------- /docs/.gitbook/assets/screen-shot-2021-09-20-at-11.03.05-am.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/docs/.gitbook/assets/screen-shot-2021-09-20-at-11.03.05-am.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/screen-shot-2021-09-20-at-5.02.15-pm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/docs/.gitbook/assets/screen-shot-2021-09-20-at-5.02.15-pm.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/screen-shot-2021-10-01-at-10.30.24-am.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coltoneshaw/3cpm/HEAD/docs/.gitbook/assets/screen-shot-2021-10-01-at-10.30.24-am.png -------------------------------------------------------------------------------- /src/app/Features/Profiles/Profiles.scss: -------------------------------------------------------------------------------- 1 | .profileDiv{ 2 | 3 | position: absolute; 4 | top: 2em; 5 | left: 5em; 6 | width: 250px; 7 | align-items: center; 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | node_modules/ 3 | node/ 4 | /node_modules 5 | src/main-app.js 6 | dist/* 7 | # build/* 8 | /src/main-app.js 9 | release/* 10 | commit.sh 11 | .env -------------------------------------------------------------------------------- /src/app/Components/Sidebar/Components/index.ts: -------------------------------------------------------------------------------- 1 | import SidebarLink from "./SidebarLink"; 2 | import SidebarNav from "./SidebarNav"; 3 | 4 | export { 5 | SidebarLink, 6 | SidebarNav 7 | } -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/Binance.ts: -------------------------------------------------------------------------------- 1 | import type { binance } from "@/types/preload"; 2 | 3 | 4 | export default interface BinanceRepository { 5 | coinData: binance['coinData'] 6 | } 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/app/Components/Buttons/Index.tsx: -------------------------------------------------------------------------------- 1 | import UpdateDataButton from "./UpdateData"; 2 | import ToggleRefreshButton from "./ToggleRefresh"; 3 | 4 | export { 5 | UpdateDataButton, 6 | ToggleRefreshButton 7 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src/main.ts", 4 | "src/preload.ts", 5 | "src/electron/*" 6 | ], 7 | "exec": "webpack --config ./webpack.electron.js && electron .", 8 | "ext": "ts" 9 | } -------------------------------------------------------------------------------- /src/app/Features/Profiles/Components/Index.tsx: -------------------------------------------------------------------------------- 1 | import ProfileNameEditor from "./ProfileNameEditor"; 2 | import ProfileSwitcher from "./ProfileSwitcher"; 3 | 4 | export { 5 | ProfileNameEditor, 6 | ProfileSwitcher 7 | } -------------------------------------------------------------------------------- /src/app/Pages/BotPlanner/Components/index.ts: -------------------------------------------------------------------------------- 1 | import DataTable from "./DataTable"; 2 | import Risk from "./Risk"; 3 | import SaveButton from "./SaveButton"; 4 | export { 5 | DataTable, 6 | Risk, 7 | SaveButton 8 | } -------------------------------------------------------------------------------- /src/app/Components/Selectors/index.ts: -------------------------------------------------------------------------------- 1 | import AccountSelector from "./AccountSelector"; 2 | import AllCurrencySelector from "./AllCurrencySelector"; 3 | 4 | export { 5 | AccountSelector, 6 | AllCurrencySelector 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/app/Features/Index.tsx: -------------------------------------------------------------------------------- 1 | import ChangelogModal from "./Changelog/ChangelogModal"; 2 | import ToastNotifcations from './ToastNotifications/ToastNotification' 3 | 4 | export { 5 | ChangelogModal, 6 | ToastNotifcations 7 | } -------------------------------------------------------------------------------- /src/app/Pages/ActiveDeals/Components/SubrowTabs/Index.tsx: -------------------------------------------------------------------------------- 1 | import DCA from "./DCA"; 2 | import Orders from "./Orders"; 3 | import OrderTimeline from "./Timeline"; 4 | 5 | export { 6 | DCA, 7 | OrderTimeline, 8 | Orders 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "git.ignoreLimitWarning": true, 3 | "liveServer.settings.port": 5501, 4 | "cSpell.words": [ 5 | "preload", 6 | "uuidv" 7 | ], 8 | "typescript.tsdk": "node_modules/typescript/lib" 9 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/Scatter/index.ts: -------------------------------------------------------------------------------- 1 | import DealPerformanceBubble from './DealPerformanceBubble' 2 | import BotPerformanceBubble from './BotPerformanceBubble' 3 | 4 | 5 | 6 | export { 7 | DealPerformanceBubble, 8 | BotPerformanceBubble 9 | } -------------------------------------------------------------------------------- /src/types/Date.ts: -------------------------------------------------------------------------------- 1 | class DateRange { 2 | public from: Date | null = null; 3 | public to: Date | null = null; 4 | } 5 | 6 | type utcDateRange = { 7 | utcEndDate: number; 8 | utcStartDate: number; 9 | } 10 | 11 | export {DateRange, utcDateRange} -------------------------------------------------------------------------------- /src/app/Pages/Stats/Type_stats.tsx: -------------------------------------------------------------------------------- 1 | type pageIds = 'summary-stats' | 'risk-monitor' | 'performance-monitor' ; 2 | type pageNames = 'Summary Statistics' | 'Risk Monitor' | 'Performance Monitor'; 3 | 4 | type buttonElements = { 5 | name: pageNames, 6 | id: pageIds 7 | }[] -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/General.ts: -------------------------------------------------------------------------------- 1 | import type { general, pm } from "@/types/preload"; 2 | 3 | 4 | export interface GeneralRepository { 5 | openLink: general['openLink'] 6 | } 7 | 8 | export interface PmRepository { 9 | versions: pm['versions'] 10 | } -------------------------------------------------------------------------------- /src/app/Pages/ActiveDeals/Components/index.ts: -------------------------------------------------------------------------------- 1 | import DealsTable from "./DealsTable"; 2 | import NotificationsSettings from "./NotificationsSettings"; 3 | import SubRowAsync from "./Subrow"; 4 | 5 | export { 6 | NotificationsSettings, 7 | SubRowAsync, 8 | DealsTable 9 | } -------------------------------------------------------------------------------- /src/app/Components/Buttons/ToggleRefresh.scss: -------------------------------------------------------------------------------- 1 | .ToggleRefreshButton .MuiLinearProgress-determinate { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | width: 100%; 8 | height: 100%; 9 | opacity: 0.1; 10 | border-radius: 4px; 11 | } -------------------------------------------------------------------------------- /src/app/Pages/Stats/Views/Index.ts: -------------------------------------------------------------------------------- 1 | import RiskMonitor from "./RiskMonitor"; 2 | import SummaryStatistics from "./SummaryStatistics" 3 | import PerformanceMonitor from './PerformanceMonitor' 4 | 5 | export { 6 | RiskMonitor, 7 | SummaryStatistics, 8 | PerformanceMonitor 9 | } -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/API.ts: -------------------------------------------------------------------------------- 1 | import type {api} from '@/types/preload' 2 | 3 | export default interface APIRepository { 4 | update: api['update']; 5 | updateBots: api['updateBots']; 6 | getAccountData: api['getAccountData'] 7 | getDealOrders: api['getDealOrders'] 8 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const electronConfigs = require('./webpack.electron.js'); 2 | const preloadConfig = require('./webpack.preload.js'); 3 | 4 | const reactConfigs = require('./webpack.react.js'); 5 | 6 | module.exports = [ 7 | electronConfigs, 8 | preloadConfig, 9 | reactConfigs 10 | ]; 11 | -------------------------------------------------------------------------------- /docs/developers/setup-a-dev-build/packaging-the-application.md: -------------------------------------------------------------------------------- 1 | # Packaging the application 2 | 3 | 1. Run steps 1 - 5 of setting the dev server up. 4 | 2. Package the application 5 | 6 | ```text 7 | npm run build 8 | ``` 9 | 10 | 1. The relevant build files will be located in `./release` 11 | 12 | -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/Database.ts: -------------------------------------------------------------------------------- 1 | import type {database, tableNames} from '@/types/preload'; 2 | 3 | export default interface DBRepository { 4 | query: database['query']; 5 | update: database['update']; 6 | upsert: database['upsert']; 7 | run: database['run']; 8 | deleteAllData: database['deleteAllData']; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/Components/DataTable/Index.tsx: -------------------------------------------------------------------------------- 1 | import formatDeals from "./FormatDeals"; 2 | import CustomTable from "./Table"; 3 | import {Bots_EditableCell, Settings_EditableCell, OpenIn3Commas} from './Components' 4 | 5 | export { 6 | formatDeals, 7 | CustomTable, 8 | Bots_EditableCell, 9 | Settings_EditableCell, 10 | OpenIn3Commas 11 | } -------------------------------------------------------------------------------- /src/app/Repositories/Impl/Binance.ts: -------------------------------------------------------------------------------- 1 | import BaseElectronRepository from "@/app/Repositories/Impl/electron/Base"; 2 | import { BinanceRepository } from '@/app/Repositories/interfaces'; 3 | 4 | export default class BaseBinanceRepository extends BaseElectronRepository implements BinanceRepository{ 5 | coinData = () => this.mainPreload.binance.coinData() 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/app/Repositories/Impl/electron/Base.ts: -------------------------------------------------------------------------------- 1 | 2 | // This gets pulled from the preload.ts file opening up the electron api to the app window. 3 | 4 | export default class BaseElectronRepository { 5 | protected mainPreload: Window["mainPreload"]; 6 | 7 | constructor(mainPreload: Window["mainPreload"]) { 8 | this.mainPreload = mainPreload; 9 | } 10 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 3C Portfolio Manager 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app/Components/DataTable/Components/index.ts: -------------------------------------------------------------------------------- 1 | import OpenIn3Commas from "./OpenIn3Commas"; 2 | import {Bots_EditableCell, Settings_EditableCell} from "./EditableCell"; 3 | import { ColumnSelector, useColumnSelector } from "./ColumnSelector"; 4 | export { 5 | OpenIn3Commas, 6 | Bots_EditableCell, 7 | Settings_EditableCell, 8 | ColumnSelector, useColumnSelector 9 | } -------------------------------------------------------------------------------- /src/app/redux/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' 2 | import type { RootState, AppDispatch } from './store' 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = () => useDispatch() 6 | export const useAppSelector: TypedUseSelectorHook = useSelector -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/Config.ts: -------------------------------------------------------------------------------- 1 | import type { config } from "@/types/preload"; 2 | 3 | 4 | export default interface ConfigRepository { 5 | get: config['get']; 6 | profile: config['profile']; 7 | getProfile: config['getProfile'] 8 | reset: config['reset'] 9 | set: config['set'] 10 | // setProfile: config['setProfile'] 11 | bulk: config['bulk'] 12 | } 13 | -------------------------------------------------------------------------------- /docs/troubleshooting/README.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Logs 4 | 5 | ### Frontend 6 | 7 | * Menu Bar > View > Toogle Developer Tools > Console 8 | 9 | ### Backend 10 | 11 | * **Windows:** `%USERPROFILE%\AppData\Roaming\3C Portfolio Manager\logs\main.log` 12 | * **Mac**: `~/Library/Logs/3C Portfolio Manager/main.log` 13 | * **Linux**: `~/.config/3C Portfolio Manager/logs/main.log` 14 | 15 | -------------------------------------------------------------------------------- /src/app/Pages/ActiveDeals/Components/EditDeal.scss: -------------------------------------------------------------------------------- 1 | div.editDealModal { 2 | background-color: var(--color-background-light); 3 | color: var(--color-text-lightbackground); 4 | 5 | .MuiDialogContentText-root{ 6 | color: var(--color-text-lightbackground); 7 | padding: 1rem; 8 | } 9 | 10 | .MuiAutocomplete-input { 11 | color: var(--color-text-lightbackground) 12 | } 13 | } -------------------------------------------------------------------------------- /assets/assets/backwardClock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/assets/coffee.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/Pages/Stats/Components/Index.tsx: -------------------------------------------------------------------------------- 1 | import NoData from "./NoData"; 2 | import RoiCards from "./RoiCards"; 3 | import SpeedometerDiv from "./SpeedometerDiv"; 4 | import {ViewRenderer, useViewRenderer} from "./ViewRenderer"; 5 | import StatFiltersDiv from './StatFiltersDiv' 6 | export { 7 | NoData, 8 | RoiCards, 9 | SpeedometerDiv, 10 | ViewRenderer, 11 | useViewRenderer, 12 | StatFiltersDiv 13 | } -------------------------------------------------------------------------------- /src/app/Pages/Stats/Components/NoData.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NoData = () => { 4 | 5 | return ( 6 | <> 7 | {/*

Error loading chart

*/} 8 |

It appears that no data was found. This most likely due to no data returned based on the selected filters. Adjust your currency, account, or start date.

9 | 10 | ) 11 | } 12 | 13 | export default NoData; -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/backwardClock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/coffee.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/Pages/Settings/Components/Index.tsx: -------------------------------------------------------------------------------- 1 | import ApiSettings from "./ApiSettings"; 2 | import ReservedBankroll from "./ReservedBankroll"; 3 | import SaveDeleteButtons from "./SaveDeleteButtons"; 4 | import StartDatePicker from "./StartDatePicker"; 5 | import CurrencySelector from "./CurrencySelector"; 6 | 7 | export { 8 | ApiSettings, 9 | CurrencySelector, 10 | ReservedBankroll, 11 | SaveDeleteButtons, 12 | StartDatePicker 13 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/CustomToolTip.ts: -------------------------------------------------------------------------------- 1 | import withStyles from '@mui/styles/withStyles'; 2 | import Tooltip from '@mui/material/Tooltip'; 3 | 4 | const CardTooltip = withStyles(() => ({ 5 | tooltip: { 6 | backgroundColor: '#f5f5f9', 7 | color: 'rgba(0, 0, 0, 0.87)', 8 | maxWidth: 220, 9 | fontSize: '.9em', 10 | fontWeight: 300, 11 | border: '1px solid #dadde9', 12 | }, 13 | }))(Tooltip); 14 | 15 | export default CardTooltip -------------------------------------------------------------------------------- /docs/frequently-asked-questions/metrics.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | 3 | ### Why does my profit or bankroll not match 3Commas? 4 | 5 | Usually, this has to do with filters. Within the 3C Portfolio Manager you're able to decide what accounts, currencies, and start dates for your data. This means there is the possibility you're filtering out data that would be shown on 3Commas. By default 3Commas will display 100% of your accounts and funds. 6 | 7 | You can adjust your filters within the 3C Portfolio Manager settings. 8 | 9 | -------------------------------------------------------------------------------- /src/app/Features/UpdateBanner/UpdateBanner.scss: -------------------------------------------------------------------------------- 1 | .update-mainDiv { 2 | width: 100vw; 3 | position: absolute; 4 | top: 0; 5 | z-index: 1000; 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: center; 9 | align-items: center; 10 | 11 | p { 12 | padding-right: 1em; 13 | } 14 | 15 | a { 16 | text-decoration: underline; 17 | cursor: pointer; 18 | } 19 | 20 | .closeIcon{ 21 | cursor: pointer; 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /src/app/Pages/BotPlanner/BotPlanner.scss: -------------------------------------------------------------------------------- 1 | .button-botPlanner { 2 | width: 200px !important; 3 | // margin: 5px 5px 10px 5px !important; 4 | // align-self: flex-end !important; 5 | margin: 5px !important 6 | } 7 | 8 | .updatebutton { 9 | width: 40px !important; 10 | } 11 | 12 | .riskDiv { 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: space-between; 16 | flex-wrap: wrap; 17 | width: 100%; 18 | gap: 2em; 19 | } 20 | 21 | #noBorder { 22 | border: none; 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/app/Pages/DailyStats/Components/index.ts: -------------------------------------------------------------------------------- 1 | import PairBar from "./Charts/PairBar"; 2 | import { queryDealByPairByDay, queryDealByBotByDay, queryProfitDataByDay, getTotalProfit, getActiveDealsFunction } from "@/app/Pages/DailyStats/Components/3Commas/dailyDashboard"; 3 | import CopyTodayStatsButton from "./CopyTodayStatsButton"; 4 | export { 5 | PairBar, 6 | queryDealByPairByDay, 7 | queryDealByBotByDay, 8 | queryProfitDataByDay, 9 | getTotalProfit, 10 | getActiveDealsFunction, 11 | CopyTodayStatsButton 12 | } -------------------------------------------------------------------------------- /src/app/Pages/Settings/Components/FeedbackOrBugButton.tsx: -------------------------------------------------------------------------------- 1 | import {Button} from "@mui/material"; 2 | import React from "react"; 3 | 4 | import { openLink } from "@/utils/helperFunctions"; 5 | 6 | const FeedbackOrBugButton = () => { 7 | 8 | return ( 9 | 10 | ) 11 | 12 | 13 | } 14 | 15 | export default FeedbackOrBugButton; 16 | -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/Repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIRepository, DealsRepository, 3 | DBRepository, BinanceRepository, ConfigRepository, PmRepository, GeneralRepository} from '@/app/Repositories/interfaces'; 4 | 5 | export default interface Repository { 6 | readonly Deals: DealsRepository; 7 | readonly API: APIRepository; 8 | readonly Database: DBRepository; 9 | readonly Binance: BinanceRepository; 10 | readonly Config: ConfigRepository; 11 | readonly General: GeneralRepository; 12 | readonly Pm: PmRepository; 13 | } -------------------------------------------------------------------------------- /src/app/Repositories/Impl/electron/Deals.ts: -------------------------------------------------------------------------------- 1 | import BaseElectronRepository from "@/app/Repositories/Impl/electron/Base"; 2 | import {UpdateDealRequest} from "@/main/3Commas/types/Deals"; 3 | import {Type_Profile} from "@/types/config"; 4 | import { DealsRepository } from '@/app/Repositories/interfaces'; 5 | 6 | export default class ElectronDealsRepository extends BaseElectronRepository implements DealsRepository { 7 | update(profileData: Type_Profile, deal: UpdateDealRequest): any { 8 | return this.mainPreload.deals.update(profileData, deal) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/Repositories/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import APIRepository from "./API"; 2 | import BinanceRepository from "./Binance"; 3 | import ConfigRepository from "./Config"; 4 | import DBRepository from "./Database"; 5 | import DealsRepository from "./Deals"; 6 | import Repository from "./Repository"; 7 | import {PmRepository, GeneralRepository} from "./General" 8 | 9 | export { 10 | APIRepository, 11 | ConfigRepository, 12 | DBRepository, 13 | DealsRepository, 14 | Repository, 15 | PmRepository, 16 | BinanceRepository, 17 | GeneralRepository 18 | } -------------------------------------------------------------------------------- /src/app/Components/icons/BackwardClock.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const BackwardClock = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export default BackwardClock; -------------------------------------------------------------------------------- /src/app/Repositories/Impl/General.ts: -------------------------------------------------------------------------------- 1 | import BaseElectronRepository from "@/app/Repositories/Impl/electron/Base"; 2 | import { GeneralRepository, PmRepository } from '@/app/Repositories/interfaces'; 3 | 4 | export class BaseGeneralRepository extends BaseElectronRepository implements GeneralRepository { 5 | openLink = ( link:string) => { 6 | this.mainPreload.general.openLink(link) 7 | } 8 | } 9 | 10 | export class BasePmRepository extends BaseElectronRepository implements PmRepository { 11 | versions = () => { 12 | return this.mainPreload.pm.versions() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /assets/assets/tradingview-v2-svgrepo-com.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/Pages/Index.ts: -------------------------------------------------------------------------------- 1 | import BotPlannerPage from "@/app/Pages/BotPlanner/BotPlanner" 2 | import TradingViewPage from "@/app/Pages/TradingView/TradingView" 3 | import SettingsPage from "@/app/Pages/Settings/Settings" 4 | import StatsPage from "@/app/Pages/Stats/Stats" 5 | import ActiveDealsPage from "@/app/Pages/ActiveDeals/ActiveDeals"; 6 | import MainWindow from "./MainWindow"; 7 | import DailyStats from '@/app/Pages/DailyStats/DailyStats' 8 | 9 | export { 10 | BotPlannerPage, 11 | TradingViewPage, 12 | SettingsPage, 13 | StatsPage, 14 | ActiveDealsPage, 15 | MainWindow, 16 | DailyStats 17 | } -------------------------------------------------------------------------------- /src/app/Repositories/Impl/electron/index.ts: -------------------------------------------------------------------------------- 1 | import ElectronAPIRepository from "@/app/Repositories/Impl/electron/API"; 2 | import ElectronBinanceRepository from "@/app/Repositories/Impl/Binance"; 3 | import ElectronConfigRepository from "@/app/Repositories/Impl/electron/Config"; 4 | import ElectronDBRepository from "@/app/Repositories/Impl/electron/Database"; 5 | import ElectronDealsRepository from "@/app/Repositories/Impl/electron/Deals"; 6 | 7 | export { 8 | ElectronAPIRepository, 9 | ElectronDealsRepository, 10 | ElectronDBRepository, 11 | ElectronConfigRepository, 12 | ElectronBinanceRepository 13 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/Bar/index.ts: -------------------------------------------------------------------------------- 1 | import DealSoUtilizationBar from './DealSoUtilizationBar'; 2 | import SoDistribution from './SoDistribution' 3 | import DealAllocationBar from './DealAllocation'; 4 | import PairPerformanceBar from './PairPerformanceBar'; 5 | import BotPerformanceBar from './BotPerformanceBar'; 6 | import ProfitByDay from "./ProfitByDay"; 7 | import SoDealDistribution from "./SoDealDistribution" 8 | 9 | 10 | 11 | export { 12 | DealSoUtilizationBar, 13 | SoDistribution, 14 | DealAllocationBar, 15 | PairPerformanceBar, 16 | BotPerformanceBar, 17 | ProfitByDay, 18 | SoDealDistribution 19 | } -------------------------------------------------------------------------------- /src/app/Features/UpdateBanner/UpdateApiFetch.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'electron-fetch' 2 | 3 | import type { Type_GithubRelease } from '@/app/Repositories/Types/GithubRelease' 4 | 5 | 6 | const fetchVersions = async () => { 7 | try { 8 | let response = await fetch('https://api.github.com/repos/coltoneshaw/3c-portfolio-manager/releases?per_page=5', 9 | { 10 | method: 'GET', 11 | timeout: 30000, 12 | }) 13 | 14 | return await response.json() 15 | } catch (e) { 16 | console.log(e); 17 | return false 18 | } 19 | 20 | } 21 | 22 | 23 | export { 24 | fetchVersions 25 | } -------------------------------------------------------------------------------- /src/app/Components/icons/Index.ts: -------------------------------------------------------------------------------- 1 | import BotPlannerIcon from "./BotPlanner"; 2 | import Coffee from "./Coffee"; 3 | import Cog from "./Cog"; 4 | import Moon from "./Moon"; 5 | import Sun from "./Sun"; 6 | import PieChart from "./PieChart"; 7 | import BackwardClock from './BackwardClock'; 8 | import ActiveDealsIcon from "./ActiveDeals"; 9 | import LoaderIcon from "./Loading/Loading"; 10 | import TradingViewLogo from "./TradingViewLogo"; 11 | 12 | export { 13 | BotPlannerIcon, 14 | Coffee, 15 | Cog, 16 | Moon, 17 | Sun, 18 | PieChart, 19 | BackwardClock, 20 | ActiveDealsIcon, 21 | LoaderIcon, 22 | TradingViewLogo 23 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/Card.scss: -------------------------------------------------------------------------------- 1 | .dataCard { 2 | width: 125px; 3 | text-align: center; 4 | color: var(--color-text-lightbackground); 5 | padding: .5em !important; 6 | height: 65px; 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | flex-grow: 1; 11 | } 12 | 13 | .dataCard h2 { 14 | font-size: 1.5em; 15 | margin: 0; 16 | font-weight: 700; 17 | opacity: .9; 18 | 19 | } 20 | 21 | .dataCard h4 { 22 | font-size: .65em; 23 | font-weight: 400; 24 | margin: 0; 25 | text-transform: capitalize; 26 | letter-spacing: .3px; 27 | padding-bottom: 2px; 28 | } -------------------------------------------------------------------------------- /src/app/Components/icons/Coffee.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Coffee = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export default Coffee; -------------------------------------------------------------------------------- /src/app/Features/CoinPriceHeader/BinanceApi.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'electron-fetch' 2 | import { BinanceTicketPrice } from '@/app/Repositories/Types/Binance' 3 | 4 | 5 | const fetchCoinPricesBinance = async () => { 6 | 7 | 8 | try { 9 | let response = await fetch('https://api.binance.com/api/v3/ticker/price', 10 | { 11 | method: 'GET', 12 | timeout: 30000, 13 | }) 14 | 15 | return await response.json() 16 | } catch (e) { 17 | console.log(e); 18 | return false 19 | } 20 | 21 | } 22 | 23 | 24 | export { 25 | fetchCoinPricesBinance 26 | } -------------------------------------------------------------------------------- /src/app/Components/Sidebar/Components/SidebarLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tooltip from '@mui/material/Tooltip'; 3 | 4 | interface Props { 5 | // In your case 6 | Icon: React.ComponentType, 7 | name: string, 8 | onClick: any 9 | } 10 | 11 | const SidebarLink = ({ Icon, name, onClick }: Props) => { 12 | 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 21 |
22 | ) 23 | 24 | } 25 | 26 | export default SidebarLink; -------------------------------------------------------------------------------- /src/main/Database/helper.ts: -------------------------------------------------------------------------------- 1 | import { chooseDatabase } from './database' 2 | 3 | const query = async (profileId: string, query: string): Promise => { 4 | const db = chooseDatabase(profileId) 5 | const row = db.prepare(query) 6 | return row.all() 7 | } 8 | 9 | 10 | const run = (profileId: string, query: string): void => { 11 | const db = chooseDatabase(profileId) 12 | const stmt = db.prepare(query); 13 | stmt.run() 14 | } 15 | 16 | function normalizeData(data: any) { 17 | if (typeof data == 'string') return data.replaceAll('?', '') 18 | if (typeof data == 'boolean') return (data) ? 1 : 0; 19 | 20 | return data; 21 | } 22 | 23 | 24 | export { query, run, normalizeData } -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/ActiveDeals.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | 6 | 7 | interface Type_Card { 8 | metric: number 9 | } 10 | 11 | /** 12 | * 13 | * @param metric - accepts the activeDealCount metric from the global data store. 14 | */ 15 | const Card_ActiveDeals = ({metric}:Type_Card) => { 16 | 17 | const title = "Active Deals" 18 | const message = descriptions.calculations.activeDeals 19 | const key = title.replace(/\s/g, '') 20 | return () 21 | 22 | } 23 | 24 | export default Card_ActiveDeals; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: coltoneshaw 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /webpack.electron.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | externals: {'better-sqlite3': 'commonjs2 better-sqlite3'}, 5 | // Build Mode 6 | mode: 'development', 7 | // Electron Entrypoint 8 | entry: './src/main/main.ts', 9 | target: 'electron-main', 10 | resolve: { 11 | alias: { 12 | ['@']: path.resolve(__dirname, 'src'), 13 | ['#']: path.resolve(__dirname, '.') 14 | 15 | }, 16 | extensions: ['.tsx', '.ts', '.js'], 17 | }, 18 | module: { 19 | rules: [{ 20 | test: /\.ts$/, 21 | include: /src/, 22 | use: [{ loader: 'ts-loader' }] 23 | }] 24 | }, 25 | output: { 26 | path: __dirname + '/dist', 27 | filename: 'main.js' 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/EnabledBots.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | 6 | 7 | interface Type_Card { 8 | metric: number 9 | } 10 | 11 | /** 12 | * 13 | * @param metric - accepts the botCount metric. This is should be locally filtered and is the total number of enabled bots. 14 | */ 15 | const Card_EnabledBots = ({ metric }: Type_Card) => { 16 | 17 | const title = "Enabled Bots" 18 | const message = descriptions.calculations.activeBots 19 | const key = title.replace(/\s/g, '') 20 | return () 21 | } 22 | 23 | export default Card_EnabledBots; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalDeals.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import { parseNumber } from "@/utils/number_formatting" 6 | 7 | 8 | interface Type_Card { 9 | metric: number 10 | } 11 | 12 | /** 13 | * 14 | * @param metric - accepts the totalDeals metric from the global data store. 15 | */ 16 | const Card_TotalDeals = ({metric}:Type_Card) => { 17 | 18 | const title = "Total Deals" 19 | const message = descriptions.metrics.totalDeals 20 | const key = title.replace(/\s/g, '') 21 | return () 22 | 23 | } 24 | 25 | export default Card_TotalDeals; -------------------------------------------------------------------------------- /src/app/Components/Sidebar/DisplaySwitcher.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { Moon, Sun } from '@/app/Components/icons/Index'; 4 | 5 | import { useThemeProvidor } from '@/app/Context/ThemeEngine'; 6 | 7 | const DisplaySwitcher = () => { 8 | 9 | const [ display, changeDisplay ] = useState(false) 10 | const { changeTheme } = useThemeProvidor() 11 | 12 | 13 | const displaySwitch = () => { 14 | changeDisplay(!display) 15 | changeTheme() 16 | } 17 | 18 | return ( 19 |
20 | { (display) ? : } 21 |
22 | ) 23 | } 24 | 25 | export default DisplaySwitcher; -------------------------------------------------------------------------------- /src/app/Features/3Commas/queryString.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Type_ReservedFunds, Type_Profile } from '@/types/config' 3 | 4 | 5 | export const getFiltersQueryString = (profileData: Type_Profile) => { 6 | const { general: { defaultCurrency }, statSettings: { reservedFunds, startDate }, id } = profileData 7 | 8 | const currencyString = (defaultCurrency) ? defaultCurrency.map((b: string) => "'" + b + "'") : "" 9 | const startString = startDate 10 | const accountIdString = reservedFunds.filter((account: Type_ReservedFunds) => account.is_enabled).map((account: Type_ReservedFunds) => account.id) 11 | 12 | return { 13 | currencyString, 14 | accountIdString, 15 | startString, 16 | currentProfileID: id 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/app/Components/Sidebar/Sidebar.scss: -------------------------------------------------------------------------------- 1 | .sidebarOption svg, 2 | #displaySwitcher svg, 3 | .icon { 4 | fill: var(--color-secondary-light50); 5 | max-height: 25px; 6 | max-width: 25px; 7 | } 8 | 9 | .sidebarOption { 10 | align-self: center; 11 | text-align: center; 12 | width: 100%; 13 | margin: 15px 0 15px 0; 14 | display: inline; 15 | } 16 | 17 | 18 | .sidebarOption .active svg, 19 | .sidebarOption:hover svg, 20 | #displaySwitcher:hover svg { 21 | fill: var(--color-secondary); 22 | } 23 | 24 | 25 | .sidebar-column{ 26 | flex-basis: 50%; 27 | } 28 | 29 | 30 | #sidebar { 31 | z-index: 1; 32 | padding-top: 25px; 33 | display: flex; 34 | flex-direction: column; 35 | justify-content: flex-start; 36 | width: 60px; 37 | } -------------------------------------------------------------------------------- /webpack.preload.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | 4 | module.exports = { 5 | externals: {'better-sqlite3': 'commonjs2 better-sqlite3', '3commas-api-node': 'commonjs2 3commas-api-node' }, 6 | // Build Mode 7 | mode: 'development', 8 | // Electron Entrypoint 9 | entry: './src/preload.ts', 10 | target: 'electron-main', 11 | resolve: { 12 | alias: { 13 | ['@']: path.resolve(__dirname, 'src'), 14 | ['#']: path.resolve(__dirname, '.') 15 | 16 | }, 17 | extensions: ['.tsx', '.ts', '.js'], 18 | }, 19 | module: { 20 | rules: [{ 21 | test: /\.ts$/, 22 | include: /src/, 23 | use: [{ loader: 'ts-loader' }] 24 | }] 25 | }, 26 | output: { 27 | path: __dirname + '/dist', 28 | filename: 'preload.js' 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/DropCoverage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import { parseNumber } from "@/utils/number_formatting" 6 | 7 | 8 | 9 | interface Type_Card { 10 | metric: number 11 | } 12 | 13 | /** 14 | * 15 | * @param metric - accepts the dropCoverage metric calculated locally. 16 | */ 17 | const Card_DropCoverage = ({metric}:Type_Card) => { 18 | 19 | const title = "Drop Coverage %" 20 | const message = descriptions.calculations.dropCoverage 21 | const key = title.replace(/\s/g, '') 22 | return () 23 | } 24 | 25 | export default Card_DropCoverage; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalBoughtVolume.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import { parseNumber } from "@/utils/number_formatting" 6 | 7 | 8 | interface Type_Card { 9 | metric: number 10 | } 11 | 12 | /** 13 | * 14 | * @param metric - accepts the boughtVolume metric from the global data store. 15 | */ 16 | const Card_TotalBoughtVolume = ({metric}:Type_Card) => { 17 | 18 | const title = "Total Bought Volume" 19 | const message = descriptions.metrics.totalBoughtVolume 20 | const key = title.replace(/\s/g, '') 21 | return () 22 | 23 | } 24 | 25 | export default Card_TotalBoughtVolume; -------------------------------------------------------------------------------- /docs/frequently-asked-questions/active-deals.md: -------------------------------------------------------------------------------- 1 | # Active Deals 2 | 3 | ### Why do my active SOs not match 3C? 4 | 5 | This is a fun one. It seems that how 3Commas handles Max Safety Trades is not how you'd expect. You can fill 5 SOs, have 1 active SO but manually set your MSTC to 0. This means that when attempting to calculate the max deal funds within the application it stops at the MSTC value, causing a mismatch in max deal funds on 3cpm and on 3commas. To mitigate this we manually update this value in the app to be the max of either MSTC or filled SOs + active SOs. 6 | 7 | ### What does auto sync do? 8 | 9 | Auto sync will update your deals and accounts every 15 seconds with information directly from 3Commas. Additionally, it turns on the ability to have push notifications about your deals closing! 10 | 11 | -------------------------------------------------------------------------------- /assets/assets/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/Features/UpdateBanner/redux/bannerSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export type banner = 'updateVersion' | '' | 'apiError' 4 | 5 | const initialState = { 6 | show: false, 7 | message: '', 8 | type: '' 9 | } 10 | 11 | 12 | export const bannerSlice = createSlice({ 13 | name: 'banner', 14 | initialState, 15 | reducers: { 16 | updateBannerData: (state, action: {payload: {show: boolean, message: string, type: banner}}) => { 17 | const {show, message, type} = action.payload 18 | state.show = show; 19 | state.message = message 20 | state.type = type 21 | } 22 | } 23 | }) 24 | 25 | export const { 26 | updateBannerData 27 | } = bannerSlice.actions; 28 | 29 | export default bannerSlice.reducer -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/MaxDca.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number, 9 | currency: (keyof typeof supportedCurrencies)[] 10 | } 11 | 12 | /** 13 | * 14 | * @param metric - accepts the `totalMaxRisk` metric from the global data store. 15 | */ 16 | const Card_MaxDca = ({metric, currency }:Type_Card) => { 17 | 18 | const title = "Max DCA" 19 | const message = descriptions.calculations.maxDca 20 | const key = title.replace(/\s/g, '') 21 | 22 | return ( ) 23 | 24 | } 25 | 26 | export default Card_MaxDca; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: coltoneshaw 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalProfit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number, 9 | currency: (keyof typeof supportedCurrencies)[] 10 | } 11 | /** 12 | * 13 | * @param metric - accepts the `totalProfit` metric from the global data store. 14 | */ 15 | const Card_TotalProfit = ({metric, currency }:Type_Card) => { 16 | 17 | const title = "Total Profit" 18 | const message = descriptions.calculations.totalProfit 19 | const key = title.replace(/\s/g, '') 20 | 21 | return ( ) 22 | 23 | } 24 | 25 | export default Card_TotalProfit; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ReactFragment } from 'react'; 2 | 3 | import CardTooltip from './CustomToolTip'; 4 | 5 | import './Card.scss'; 6 | import { AnyStyledComponent } from 'styled-components'; 7 | // " is calculated by taking your total DCA Max Risk of 35,746 and dividing it by your current bankroll of 14,644." 8 | 9 | const Card = ({ title = "", metric, message = "", SubMetric }: { title: string, metric: {metric: string | number, symbol: string}, message?: string, SubMetric?: AnyStyledComponent,}) => { 10 | 11 | return ( 12 | {title} {message} } > 13 |
14 |

{title}

15 |

{metric.metric}

16 |
17 |
) 18 | 19 | } 20 | 21 | export default Card; -------------------------------------------------------------------------------- /src/app/Components/icons/TradingViewLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const TradingViewLogo = () => { 4 | 5 | return ( 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default TradingViewLogo; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/AverageDailyProfit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | 8 | interface Type_Card { 9 | metric: number, 10 | currency: (keyof typeof supportedCurrencies)[] 11 | } 12 | 13 | /** 14 | * 15 | * @param metric - accepts the averageDailyProfit metric from the global data store. 16 | */ 17 | const Card_AverageDailyProfit = ({metric, currency}:Type_Card) => { 18 | 19 | const title = "Average Daily Profit" 20 | const message = descriptions.metrics.averageDailyProfit 21 | const key = title.replace(/\s/g, '') 22 | return ( ) 23 | } 24 | 25 | export default Card_AverageDailyProfit; -------------------------------------------------------------------------------- /src/app/Pages/DailyStats/Components/3Commas/type_dailydashboard.ts: -------------------------------------------------------------------------------- 1 | 2 | type queryDealByPairByDayQuery = { 3 | pair: string[], 4 | averageHourlyProfitPercent: number, 5 | totalProfit: number, 6 | numberOfDeals: number, 7 | boughtVolume: number, 8 | averageDealHours: number 9 | } 10 | 11 | 12 | type queryDealByPairByDayReturn = queryDealByPairByDayQuery & { 13 | percentTotalVolume: number; 14 | percentTotalProfit: number; 15 | } 16 | 17 | 18 | type botQueryDealByDayQuery = { 19 | bot_name: string, 20 | bot_id: number, 21 | averageHourlyProfitPercent: number, 22 | totalProfit: number, 23 | numberOfDeals: number, 24 | boughtVolume: number, 25 | averageDealHours: number 26 | } 27 | 28 | 29 | type botQueryDealByDayReturn = botQueryDealByDayQuery & { 30 | percentTotalVolume: number; 31 | percentTotalProfit: number; 32 | } 33 | -------------------------------------------------------------------------------- /src/app/Components/icons/Moon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Moon = () => { 4 | 5 | return ( 6 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default Moon; -------------------------------------------------------------------------------- /src/app/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import configSlice from '@/app/redux/config/configSlice' 3 | import threeCommasSlice from '@/app/redux/threeCommas/threeCommasSlice' 4 | import settingsSlice from '@/app/Pages/Settings/Redux/settingsSlice' 5 | import bannerSlice from '@/app/Features/UpdateBanner/redux/bannerSlice' 6 | 7 | const store = configureStore({ 8 | reducer: { 9 | config: configSlice, 10 | threeCommas: threeCommasSlice, 11 | settings: settingsSlice, 12 | banner: bannerSlice 13 | }, 14 | devTools​: true 15 | }) 16 | 17 | // Infer the `RootState` and `AppDispatch` types from the store itself 18 | export type RootState = ReturnType 19 | 20 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} 21 | export type AppDispatch = typeof store.dispatch 22 | 23 | export default store; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalDayProfit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number, 9 | currency: (keyof typeof supportedCurrencies)[] 10 | } 11 | 12 | /** 13 | * 14 | * @param metric - accepts today's profit which can be calculated with `profitData[profitData.length - 1].profit` from the global data store. 15 | */ 16 | const Card_TotalDayProfit = ({metric, currency }:Type_Card) => { 17 | 18 | const title = "Today's Profit" 19 | const message = descriptions.metrics.todaysProfit 20 | const key = title.replace(/\s/g, '') 21 | 22 | return ( ) 23 | 24 | } 25 | 26 | export default Card_TotalDayProfit; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalUnrealizedProfit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number, 9 | currency: (keyof typeof supportedCurrencies)[] 10 | } 11 | 12 | /** 13 | * 14 | * @param metric - accepts the unrealized profit metric which is ( deal.take_profit / 100 ) * deal.bought_volume) 15 | */ 16 | const Card_TotalUnrealizedProfit = ({metric, currency }:Type_Card) => { 17 | 18 | const title = "Unrealized Profit" 19 | const message = descriptions.metrics.totalUnrealizedProfit 20 | const key = title.replace(/\s/g, '') 21 | 22 | return ( ) 23 | 24 | } 25 | 26 | export default Card_TotalUnrealizedProfit; -------------------------------------------------------------------------------- /src/app/Components/Sidebar/Components/SidebarNav.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Tooltip from '@mui/material/Tooltip'; 3 | import { NavLink } from 'react-router-dom' 4 | import { setStorageItem, storageItem } from '@/app/Features/LocalStorage/LocalStorage'; 5 | 6 | interface Props { 7 | // In your case 8 | Icon: React.ComponentType, 9 | name: string, 10 | link: string, 11 | } 12 | 13 | const SidebarNav= ({ Icon, name, link }: Props) => { 14 | 15 | 16 | return ( 17 |
setStorageItem(storageItem.navigation.homePage, link)}> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | ) 27 | 28 | } 29 | 30 | export default SidebarNav; -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | * [3C Portfolio Manager](README.md) 4 | * [Frequently Asked Questions](frequently-asked-questions/README.md) 5 | * [Metrics](frequently-asked-questions/metrics.md) 6 | * [Active Deals](frequently-asked-questions/active-deals.md) 7 | * [Google Sheets](frequently-asked-questions/google-sheets.md) 8 | 9 | ## Configuration 10 | 11 | * [Initial Setup](configuration/initial-setup.md) 12 | * [Profiles](configuration/profiles.md) 13 | 14 | ## Developers 15 | 16 | * [3Commas API](developers/3commas-api.md) 17 | * [Setup a dev build](developers/setup-a-dev-build/README.md) 18 | * [Packaging the application](developers/setup-a-dev-build/packaging-the-application.md) 19 | 20 | *** 21 | 22 | * [Changelog](changelog.md) 23 | * [Troubleshooting](troubleshooting/README.md) 24 | * [Migrate to v1](troubleshooting/migrate-to-v1.md) 25 | * [Error Syncing](troubleshooting/error-syncing.md) 26 | * [Donate](https://www.buymeacoffee.com/ColtonS) 27 | -------------------------------------------------------------------------------- /src/main/Config/migrations/2.0.0.ts: -------------------------------------------------------------------------------- 1 | import { TconfigValues, Type_Profile } from "@/types/config"; 2 | import log from 'electron-log'; 3 | import { checkOrMakeTables } from "@/main/Database/database"; 4 | import fsExtra from 'fs-extra'; 5 | import path from "path"; 6 | import { app } from "electron"; 7 | 8 | const appDataPath = app.getPath('userData'); 9 | 10 | 11 | 12 | 13 | export const convertToProfileDatabases = async (profileIds: string[]) => { 14 | if (!profileIds || profileIds.length === 0) return 15 | 16 | await fsExtra.mkdir(path.join(appDataPath, 'databases')); 17 | for (let profileId of profileIds) { 18 | log.info(`Converting ${profileId} to it's own database`) 19 | checkOrMakeTables(profileId) 20 | } 21 | fsExtra.remove(path.join(appDataPath, 'db.sqlite3'), err => { 22 | if (err) return log.error('Unable to delete original database file', err) 23 | log.info('Deleted db.sqlite3 file from user directory') 24 | }) 25 | 26 | } -------------------------------------------------------------------------------- /assets/assets/pieChart.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/app/Repositories/Impl/electron/API.ts: -------------------------------------------------------------------------------- 1 | import { Type_UpdateFunction } from "@/types/3Commas"; 2 | import { Type_Profile } from "@/types/config"; 3 | import BaseElectronRepository from "@/app/Repositories/Impl/electron/Base"; 4 | import { APIRepository } from '@/app/Repositories/interfaces'; 5 | 6 | export default class ElectronAPIRepository extends BaseElectronRepository implements APIRepository { 7 | update = (type: string, options: Type_UpdateFunction, profileData: Type_Profile) => this.mainPreload.api.update(type, options, profileData) 8 | updateBots = (profileData: Type_Profile) => this.mainPreload.api.updateBots(profileData) 9 | getAccountData = (profileData?: Type_Profile, key?: string, secret?: string, mode?: string) => { 10 | return this.mainPreload.api.getAccountData(profileData, key, secret, mode) 11 | } 12 | getDealOrders = (profileData: Type_Profile, dealID: number) => { 13 | return this.mainPreload.api.getDealOrders(profileData, dealID) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/pieChart.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/app/Features/ToastNotifications/ToastNotification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Snackbar, IconButton} from '@mui/material'; 3 | 4 | import CloseIcon from '@mui/icons-material/Close'; 5 | 6 | interface Type_Snack { 7 | open: boolean 8 | handleClose: any 9 | message: string 10 | } 11 | 12 | const ToastNotifcations = ({open, handleClose, message}: Type_Snack) => { 13 | return ( 14 | <> 15 | 26 | 27 | 28 | 29 | 30 | } 31 | /> 32 | 33 | ); 34 | } 35 | 36 | export default ToastNotifcations; 37 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/ActiveDealReserve.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number, 9 | currency: (keyof typeof supportedCurrencies)[] 10 | } 11 | 12 | /** 13 | * 14 | * @param metric - accepts a sum of the active deal reserves. Which is just a total of `actual_usd_profit` together. 15 | */ 16 | const Card_ActiveDealReserve = ({metric, currency }:Type_Card) => { 17 | 18 | const title = "Active Deal Reserve" 19 | const message = descriptions.metrics.activeDealReserves 20 | const key = title.replace(/\s/g, '') 21 | 22 | // overriding the currency here since the values are all currently in USD. 23 | currency = ['USD'] 24 | return ( ) 25 | } 26 | 27 | export default Card_ActiveDealReserve; -------------------------------------------------------------------------------- /scripts/notarize.js: -------------------------------------------------------------------------------- 1 | // inspired by https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/ 2 | require('dotenv').config(); 3 | const {notarize} = require('electron-notarize'); 4 | 5 | 6 | exports.default = async function notarizing(context) { 7 | const {electronPlatformName, appOutDir} = context; 8 | if (electronPlatformName !== 'darwin' || process.platform !== 'darwin') { 9 | return; 10 | } 11 | 12 | const appName = context.packager.appInfo.productFilename; 13 | if (typeof process.env.APPLEID === 'undefined') { 14 | console.log('skipping notarization, remember to setup environment variables for APPLEID and APPLEIDPASS if you want to notarize'); 15 | return; 16 | } 17 | return await notarize({ 18 | appBundleId: 'com.savvytoolbelt.3cportfoliomanager', 19 | appPath: `${appOutDir}/${appName}.app`, 20 | appleId: process.env.APPLEID, 21 | appleIdPassword: process.env.APPLEIDPASS, 22 | tool: 'notarytool', 23 | teamId: '4UHVHSRL22' 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/AverageDealHours.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import { parseNumber } from "@/utils/number_formatting" 6 | 7 | 8 | interface Type_Card { 9 | metric: number, 10 | additionalData: { 11 | totalClosedDeals: number 12 | totalDealHours: number 13 | } 14 | } 15 | 16 | /** 17 | * 18 | * @param metric - accepts the averageDailyProfit metric from the global data store. 19 | */ 20 | const Card_AverageDealHours = ({metric, additionalData}:Type_Card) => { 21 | 22 | const {totalClosedDeals, totalDealHours} = additionalData 23 | 24 | const title = "Avg. Deal Hours" 25 | const message = descriptions.calculations.averageDealHours( totalClosedDeals, totalDealHours) 26 | const key = title.replace(/\s/g, '') 27 | return ( 28 | 29 | ) 30 | } 31 | 32 | export default Card_AverageDealHours; -------------------------------------------------------------------------------- /src/app/Features/Profiles/Components/ProfileNameEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TextField } from '@mui/material'; 3 | import { useAppSelector, useAppDispatch } from '@/app/redux/hooks'; 4 | import { configPaths } from "@/app/redux/globalFunctions"; 5 | import { updateEditProfileByPath } from "@/app/Pages/Settings/Redux/settingsSlice"; 6 | 7 | const ProfileNameEditor = () => { 8 | const { name } = useAppSelector(state => state.settings.editingProfile); 9 | const dispatch = useAppDispatch() 10 | const handleChange = (e: any) => { 11 | dispatch(updateEditProfileByPath({ data: e.target.value, path: configPaths.name })) 12 | } 13 | 14 | return ( 15 | 27 | ) 28 | } 29 | 30 | export default ProfileNameEditor; -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalInDeals.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number 9 | additionalData: { on_orders: number, totalBoughtVolume:number } 10 | currency: (keyof typeof supportedCurrencies)[] 11 | 12 | } 13 | 14 | /** 15 | * 16 | * @param metric - accepts the activeDealCount metric from the global data store. 17 | * @param additionalData - requires on_orders and totalBoughtVolume to be passed in. 18 | */ 19 | const Card_totalInDeals = ({metric, currency, additionalData: { on_orders, totalBoughtVolume }}:Type_Card) => { 20 | 21 | const title = "In deals" 22 | const message = descriptions.calculations.totalInDeals(on_orders, totalBoughtVolume, currency) 23 | const key = title.replace(/\s/g, '') 24 | 25 | return ( 26 | 27 | ) 28 | } 29 | 30 | export default Card_totalInDeals; -------------------------------------------------------------------------------- /assets/assets/activeDeals.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalBankRoll.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import {formatCurrency, supportedCurrencies} from'@/utils/granularity' 6 | 7 | interface Type_Card { 8 | metric: number 9 | currency: (keyof typeof supportedCurrencies)[] 10 | additionalData: { position: number, totalBoughtVolume: number, reservedFundsTotal: number} 11 | } 12 | 13 | /** 14 | * 15 | * @param metric - accepts the `totalBankroll` metric from the global data store. 16 | * @param 17 | */ 18 | const Card_TotalBankRoll = ({metric, additionalData, currency }:Type_Card) => { 19 | 20 | const { position, totalBoughtVolume, reservedFundsTotal } = additionalData 21 | 22 | const title = "Total bankroll" 23 | const message = descriptions.calculations.totalBankRoll(position, totalBoughtVolume, reservedFundsTotal, currency) 24 | const key = title.replace(/\s/g, '') 25 | 26 | return ( ) 27 | 28 | } 29 | 30 | export default Card_TotalBankRoll; -------------------------------------------------------------------------------- /src/app/Pages/BotPlanner/Components/SaveButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ToastNotifcations } from '@/app/Features/Index' 3 | import { Button } from '@mui/material'; 4 | import SaveIcon from '@mui/icons-material/Save'; 5 | 6 | interface Type_SaveButton { 7 | saveFunction: any 8 | className: string 9 | } 10 | 11 | const SaveButton = ({saveFunction, className} : Type_SaveButton ) => { 12 | 13 | const [open, setOpen] = React.useState(false); 14 | 15 | const handleClose = (event: any, reason: string) => { 16 | if (reason === 'clickaway') { 17 | return; 18 | } 19 | 20 | setOpen(false); 21 | }; 22 | 23 | 24 | return ( 25 | <> 26 | 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | export default SaveButton; -------------------------------------------------------------------------------- /src/app/Repositories/Impl/electron/Config.ts: -------------------------------------------------------------------------------- 1 | import BaseElectronRepository from "@/app/Repositories/Impl/electron/Base"; 2 | import { ConfigRepository } from '@/app/Repositories/interfaces'; 3 | import { Type_Profile } from "@/types/config"; 4 | import type { defaultConfig } from "@/utils/defaultConfig"; 5 | 6 | export default class ElectronConfigRepository extends BaseElectronRepository implements ConfigRepository { 7 | get = (value: 'all' | string) => { 8 | return this.mainPreload.config.get(value) 9 | } 10 | profile = (type: 'create', profileData: Type_Profile, profileId: string) => { 11 | return this.mainPreload.config.profile(type, profileData, profileId) 12 | } 13 | getProfile = (value: string, profileId: string) => { 14 | return this.mainPreload.config.getProfile(value, profileId) 15 | } 16 | reset = async () => { 17 | await this.mainPreload.config.reset() 18 | } 19 | set = async (key: string, value: any) => { 20 | await this.mainPreload.config.set(key, value) 21 | } 22 | bulk = async (changes: typeof defaultConfig) => { 23 | await this.mainPreload.config.bulk(changes) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /assets/assets/cog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/MaxRiskPercent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import { parseNumber } from "@/utils/number_formatting" 6 | import type {supportedCurrencies} from'@/utils/granularity' 7 | 8 | interface Type_Card { 9 | metric: number, 10 | additionalData: { maxDCA:number , totalBankroll:number, inactiveBotFunds:number } 11 | currency: (keyof typeof supportedCurrencies)[] 12 | 13 | } 14 | 15 | /** 16 | * 17 | * @param metric - accepts the risk metric calculated locally. 18 | * @param additionalData - accepts totalBankroll, maxDCA 19 | */ 20 | const Card_MaxRiskPercent = ({metric, additionalData, currency}:Type_Card) => { 21 | 22 | const { totalBankroll, maxDCA, inactiveBotFunds } = additionalData; 23 | 24 | const title = "Risk %" 25 | const message = descriptions.calculations.risk(maxDCA, totalBankroll, inactiveBotFunds, currency) 26 | const key = title.replace(/\s/g, '') 27 | return () 28 | 29 | } 30 | 31 | export default Card_MaxRiskPercent; -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/cog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/Components/icons/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Loading.scss' 4 | 5 | const LoaderIcon = () => { 6 | 7 | return ( 8 | 10 | 11 | 15 | 16 | 17 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | export default LoaderIcon; -------------------------------------------------------------------------------- /FORMULA_DESCRIPTIONS.md: -------------------------------------------------------------------------------- 1 | ## Bank Roll Calculations 2 | 3 | ### Total Bankroll 4 | Formula: 5 | `Total Bankroll = Position ( Funds in that currency ) + funds currently in deals` 6 | 7 | Additional information: 8 | - What is in position? 9 | - Position is a sum of what you have in active deals + what you have in available funds. This 10 | - Why doesn't it match exactly to my crypto account? 11 | - Your crypto account also takes into account any coins that you hold that are not a part of your DCA bots. For example, if you've made a smart trade, holding coins, etc. 12 | - Additionally, the data from your crypto account to 3Commas is not always up to date, there may be slight variances in the numbers. But you should see within 1-4% the number is right. 13 | 14 | ### Bankroll Available: 15 | Formula: 16 | ` ( 1 - ( ( funds currently in deals ) / ( Total Bankroll )) ) * 100 = remaining bankroll percent` 17 | 18 | 19 | Additional information: 20 | - This takes into account all the bankroll you have for the selected currency and gives the percent remaining after you remove what's on an order plus funds in a deal. 21 | - This is calculated within the `calculateMetrics` inside `DataContext.js` 22 | -------------------------------------------------------------------------------- /assets/assets/power.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 9 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/metrics/TotalRoi.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../Card"; 4 | import descriptions from "@/descriptions"; 5 | import { parseNumber } from '@/utils/number_formatting' 6 | import type {supportedCurrencies} from'@/utils/granularity' 7 | 8 | interface Type_Card { 9 | additionalData: { totalProfit: number, totalBankroll: number} 10 | currency: (keyof typeof supportedCurrencies)[] 11 | title: string 12 | 13 | } 14 | 15 | /** 16 | * 17 | * @param additionalData - accepts the `totalProfit_perf` and `boughtVolume` metric from the global data store. 18 | * @param 19 | */ 20 | const Card_TotalRoi = ({additionalData, currency, title}:Type_Card) => { 21 | 22 | const { totalProfit, totalBankroll } = additionalData 23 | 24 | // 25 | 26 | // const title = "Total ROI" 27 | const message = descriptions.calculations.totalRoi(totalProfit, totalBankroll, currency) 28 | const key = title.replace(/\s/g, '') 29 | const metric = parseNumber( ( ( totalProfit / ( totalBankroll - totalProfit)) * 100 ), 2) + "%" 30 | 31 | return () 32 | } 33 | 34 | export default Card_TotalRoi; -------------------------------------------------------------------------------- /src/app/Features/3Commas/Type_3Commas.ts: -------------------------------------------------------------------------------- 1 | 2 | type fetchDealDataFunctionQuery = { 3 | closed_at_str: string, 4 | final_profit: number 5 | deal_hours: number 6 | total_deals: number 7 | } 8 | 9 | type fetchPerformanceData = { 10 | performance_id: string, 11 | bot_name: string, 12 | pair: string[], 13 | averageHourlyProfitPercent: number, 14 | total_profit: number, 15 | number_of_deals: number, 16 | bought_volume: number, 17 | averageDealHours: number 18 | } 19 | 20 | type fetchBotPerformanceMetrics = { 21 | bot_id: number, 22 | pairs: string, 23 | total_profit: string, 24 | avg_profit: number, 25 | number_of_deals : number, 26 | bought_volume : number, 27 | avg_deal_hours : number, 28 | avg_completed_so: number, 29 | bot_name : string, 30 | type: 'Bot::SingleBot' | 'Bot::MultiBot' 31 | } 32 | 33 | 34 | type fetchPairPerformanceMetrics = { 35 | pair:string 36 | avg_completed_so: number 37 | total_profit: number 38 | number_of_deals: number 39 | avg_profit: number 40 | bought_volume: number 41 | avg_deal_hours: number 42 | } 43 | 44 | type fetchSoData = { 45 | completed_safety_orders_count : number, 46 | total_profit: number, 47 | total_deals : number 48 | } -------------------------------------------------------------------------------- /src/app/Components/icons/PieChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PieChart = () => { 4 | return ( 5 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | export default PieChart; -------------------------------------------------------------------------------- /docs/troubleshooting/migrate-to-v1.md: -------------------------------------------------------------------------------- 1 | # Migrate to v1 2 | 3 | Migrating to v1.0.0 from any prior release can contain a bug or two due to the level of backend changes that have occurred. If you cannot sync, are missing data, or something doesn't feel right try the below troubleshooting steps. 4 | 5 | 1. Reset your profile 6 | * Menu Bar > Help > Reset profile 7 | * This will refresh the page and delete all the data for the profile. Once it's reset you can click the refresh button. If this doesn't resolve the profile try the below. 8 | 2. Delete the application contents, **not** the application. 9 | 1. Based on your operating system navigate to the folder where the application contents are stored. 10 | * **Windows:** `%USERPROFILE%\AppData\Roaming\3C Portfolio Manager` 11 | * **Mac**: `~/Library/Application Support/3C Portfolio Manager` 12 | * **Linux**: `~/.config/3C Portfolio Manager` 13 | 2. Close the 3C Portfolio Manager application 14 | 3. You can delete the **entire** folder titled `3C Portfolio Manager` 15 | 4. Re-open the application 16 | 5. Follow the setup steps outlined under [Creating a Profile](../configuration/profiles.md#creating) 17 | 3. If you continue to have an [issue open a ticket or reach out to me ](../#feedback-or-bug-submission) 18 | 19 | -------------------------------------------------------------------------------- /src/app/Components/DataTable/Components/OpenIn3Commas.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import LaunchIcon from '@mui/icons-material/Launch'; 4 | 5 | import {openLink} from '@/utils/helperFunctions' 6 | import { textAlign } from "@mui/system"; 7 | 8 | const OpenIn3Commas = ({ cell, bot_id, className }: { cell: any, bot_id: string, className?: string }) => { 9 | 10 | 11 | const url = (bot_id) ? `https://3commas.io/bots/${bot_id}/edit` : `https://3commas.io/bots` 12 | 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 28 | {cell.value} 29 | openLink(url)} 37 | /> 38 | 39 | 40 | ) 41 | } 42 | 43 | export default OpenIn3Commas; -------------------------------------------------------------------------------- /src/app/Repositories/Impl/electron/Database.ts: -------------------------------------------------------------------------------- 1 | import BaseElectronRepository from "@/app/Repositories/Impl/electron/Base"; 2 | import { DBRepository } from '@/app/Repositories/interfaces'; 3 | import { tableNames } from "@/types/preload"; 4 | 5 | export default class ElectronDBRepository extends BaseElectronRepository implements DBRepository { 6 | query = async (profileId:string, queryString:string) => await this.mainPreload.database.query(profileId, queryString); 7 | update = (profileId:string, table:tableNames, data:object[]) => { 8 | if(!data || data.length === 0){ 9 | console.log('no data to update'); 10 | return 11 | } 12 | this.mainPreload.database.update(profileId, table, data); 13 | } 14 | upsert = (profileId:string, table:tableNames, data:any[], id:string, updateColumn:string) => { 15 | if(!data || data.length === 0){ 16 | console.log('no data to update'); 17 | return 18 | } 19 | this.mainPreload.database.upsert(profileId, table, data, id, updateColumn); 20 | } 21 | run = (profileId:string, query:string) => this.mainPreload.database.run(profileId, query); 22 | deleteAllData = async (profileID?: string) => await this.mainPreload.database.deleteAllData(profileID); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/Pages/Settings/Redux/settingsSlice.ts: -------------------------------------------------------------------------------- 1 | import { Type_Profile } from '@/types/config'; 2 | import { defaultProfile } from '@/utils/defaultConfig'; 3 | import { createSlice } from '@reduxjs/toolkit'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { updateProfileByPath } from '@/app/redux/globalFunctions'; 6 | const initialState = { 7 | editingProfile: defaultProfile 8 | } 9 | 10 | export const settingsSlice = createSlice({ 11 | name: 'settings', 12 | initialState, 13 | reducers: { 14 | setEditingProfile: (state, action) => { 15 | state.editingProfile = action.payload 16 | }, 17 | updateEditProfileByPath: (state, action: { payload: { data: string | {} | [], path: any}}) => { 18 | const { data, path } = action.payload 19 | const newProfile = updateProfileByPath(data, Object.assign({}, { ...state.editingProfile }), path) 20 | state.editingProfile = newProfile 21 | }, 22 | addEditingProfile: state => { 23 | state.editingProfile = { ...defaultProfile, id: uuidv4() } 24 | }, 25 | 26 | } 27 | }) 28 | 29 | export const { 30 | addEditingProfile, 31 | updateEditProfileByPath, 32 | setEditingProfile 33 | } = settingsSlice.actions; 34 | 35 | export default settingsSlice.reducer -------------------------------------------------------------------------------- /src/app/Components/icons/Cog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Cog = () => { 4 | return ( 5 | 6 | 8 | 9 | ) 10 | } 11 | 12 | export default Cog; -------------------------------------------------------------------------------- /src/app/Components/icons/ActiveDeals.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ActiveDealsIcon = () => { 4 | return ( 5 | 7 | 8 | 10 | 12 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | export default ActiveDealsIcon; -------------------------------------------------------------------------------- /docs/configuration/initial-setup.md: -------------------------------------------------------------------------------- 1 | # Initial Setup 2 | 3 | 1. Download the latest release from [GitHub](https://github.com/coltoneshaw/3c-portfolio-manager/releases/) 4 | * Depending on how you're downloading this file you could get an unsafe file warning. For more information read [Why should I trust this?](../#why-should-i-trust-this) and [Unsafe File Warning](../#unsafe-file-warning). 5 | * You'll find the download links under `Assets`. 6 | 2. Run the installer on your computer \(or a local VM\) 7 | 3. Generate API keys from 3Commas. **These are not the same as your exchange keys** 8 | 1. Go to [3commas.io](www.3commas.io) 9 | 2. Top right click your email > API Keys ![](../.gitbook/assets/screen-shot-2021-09-20-at-11.03.05-am.png) 10 | 3. Click "New API Access Token" 11 | 4. Select all three **read-only** properties. 12 | 5. Save and use in the next step 13 | 4. Add your API keys to the application, run `Test API keys` 14 | * This will download your accounts and confirm the API keys are valid. 15 | 5. Choose a filter currency, the start date, and enable an account. 16 | * If you have funds in your account that you **do not** want to be used in calculations add that to the `Reserved Bankroll` field 17 | 6. Click Save. 18 | * This will download the deal data, bots, and account information from 3commas. This can take anywhere from 15 seconds to 4 minutes. 19 | 7. Start to profit 20 | 21 | -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/botmanager.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/app/Components/Charts/Speedometer/MaxRiskPercent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactSpeedometer from "react-d3-speedometer" 3 | 4 | interface Type_Speedometer { 5 | metric: number 6 | min: number 7 | max: number 8 | colorArray?: string[] 9 | labelArray: object[] 10 | title:string 11 | } 12 | 13 | const MaxRiskSpeedometer = ({ metric, min, max, colorArray, labelArray, title }:Type_Speedometer) => { 14 | 15 | return ( 16 |
21 |
25 |

{title}

26 | max) ? max : metric} 31 | currentValueText={`${metric}%`} 32 | needleColor="steelblue" 33 | segments={5} 34 | segmentColors={colorArray} 35 | customSegmentLabels={labelArray} 36 | /> 37 |
38 | 39 |
40 | 41 | ) 42 | 43 | } 44 | 45 | 46 | 47 | export default MaxRiskSpeedometer; -------------------------------------------------------------------------------- /assets/assets/botmanager.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/app/Pages/Settings/Components/StartDatePicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { isValid } from 'date-fns' 3 | import moment from "moment"; 4 | 5 | import DateTimePicker from '@mui/lab/DateTimePicker'; 6 | import { TextField, FormControl } from '@mui/material'; 7 | 8 | import { useAppSelector, useAppDispatch } from '@/app/redux/hooks'; 9 | import { configPaths } from "@/app/redux/globalFunctions"; 10 | import { updateEditProfileByPath } from "@/app/Pages/Settings/Redux/settingsSlice"; 11 | 12 | export default function StartDatePicker() { 13 | 14 | const startDate = useAppSelector(state => state.settings.editingProfile.statSettings.startDate); 15 | const dispatch = useAppDispatch() 16 | 17 | const returnTodayUtcEnd = (date: Date) => moment.utc(date).endOf("day").valueOf(); 18 | 19 | const handleDateChange = (date: Date | null) => { 20 | if (date != undefined && isValid(date))dispatch(updateEditProfileByPath({ data: moment(date).valueOf(), path: configPaths.statSettings.startDate })) 21 | }; 22 | 23 | return ( 24 | 25 | } 31 | className="desktopPicker" 32 | /> 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/Components/Charts/DataCards/index.ts: -------------------------------------------------------------------------------- 1 | import Card_ActiveDeals from "./metrics/ActiveDeals"; 2 | import Card_totalInDeals from "./metrics/TotalInDeals"; 3 | import Card_MaxDca from "./metrics/MaxDca"; 4 | import Card_TotalBankRoll from "./metrics/TotalBankRoll"; 5 | import Card_TotalProfit from "./metrics/TotalProfit"; 6 | import Card_EnabledBots from "./metrics/EnabledBots"; 7 | import Card_DropCoverage from "./metrics/DropCoverage"; 8 | import Card_MaxRiskPercent from "./metrics/MaxRiskPercent"; 9 | import Card_TotalBoughtVolume from "./metrics/TotalBoughtVolume"; 10 | import Card_TotalDeals from "./metrics/TotalDeals"; 11 | import Card_TotalRoi from "./metrics/TotalRoi"; 12 | import Card_AverageDailyProfit from "./metrics/AverageDailyProfit"; 13 | import Card_AverageDealHours from "./metrics/AverageDealHours"; 14 | import Card_TotalDayProfit from './metrics/TotalDayProfit' 15 | import Card_ActiveDealReserve from "./metrics/ActiveDealReserve"; 16 | import Card_TotalUnrealizedProfit from "./metrics/TotalUnrealizedProfit"; 17 | 18 | 19 | export { 20 | Card_ActiveDeals, 21 | Card_totalInDeals, 22 | Card_MaxDca, 23 | Card_TotalBankRoll, 24 | Card_TotalProfit, 25 | Card_EnabledBots, 26 | Card_DropCoverage, 27 | Card_MaxRiskPercent, 28 | Card_TotalBoughtVolume, 29 | Card_TotalDeals, 30 | Card_TotalRoi, 31 | Card_AverageDailyProfit, 32 | Card_AverageDealHours, 33 | Card_TotalDayProfit, 34 | Card_ActiveDealReserve, 35 | Card_TotalUnrealizedProfit 36 | } -------------------------------------------------------------------------------- /src/types/Charts.ts: -------------------------------------------------------------------------------- 1 | import { Type_Profit, Type_Query_PerfArray, Type_ActiveDeals, Type_MetricData, Type_Bot_Performance_Metrics, Type_Pair_Performance_Metrics, Type_SoDistributionArray } from '@/types/3Commas'; 2 | 3 | import type {defaultCurrency} from '@/types/config' 4 | export interface Type_SoDistribution { 5 | data: Type_ActiveDeals[] 6 | metrics: Type_MetricData 7 | defaultCurrency: defaultCurrency 8 | } 9 | 10 | export type Type_SoDealDis = { 11 | defaultCurrency: defaultCurrency 12 | data: Type_SoDistributionArray[] | undefined 13 | } 14 | 15 | 16 | export interface Type_ProfitChart { 17 | data: Type_Profit[] 18 | X: string 19 | defaultCurrency: defaultCurrency 20 | } 21 | 22 | export interface Type_Pair_Performance { 23 | data: Type_Pair_Performance_Metrics[] | undefined | [] 24 | defaultCurrency: defaultCurrency 25 | 26 | } 27 | 28 | 29 | export interface Type_Tooltip { 30 | active: boolean 31 | payload: any[] 32 | label: string, 33 | formatter: Function 34 | } 35 | 36 | export interface Type_DealPerformanceCharts{ 37 | data: Type_Query_PerfArray[] | undefined | [] 38 | defaultCurrency: defaultCurrency 39 | 40 | } 41 | 42 | export interface Type_BotPerformanceCharts{ 43 | data: Type_Bot_Performance_Metrics[] | undefined | [] 44 | defaultCurrency: defaultCurrency 45 | 46 | } 47 | 48 | export interface Type_ActiveDealCharts{ 49 | // title: string 50 | data: Type_ActiveDeals[] 51 | defaultCurrency: defaultCurrency 52 | 53 | } -------------------------------------------------------------------------------- /src/app/Pages/DailyStats/DailyStats.scss: -------------------------------------------------------------------------------- 1 | 2 | .green-text { 3 | color: var(--color-green) 4 | } 5 | 6 | .red-text { 7 | color: var(--color-red) 8 | } 9 | 10 | .kpiMetricsContainer { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | 15 | 16 | .metrics { 17 | flex: 1; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: center; 21 | 22 | p, .roi-percent { 23 | margin: 0; 24 | padding: .2em 0; 25 | font-size: 1.1em; 26 | white-space: nowrap; 27 | } 28 | 29 | strong { 30 | margin: 0 .4em 0 0; 31 | font-size: 1.1em; 32 | font-weight: 700; 33 | letter-spacing: .5px; 34 | padding: .2em 0; 35 | } 36 | 37 | @media only screen and (max-width: 1550px){ 38 | p, .roi-percent { 39 | font-size: 1em; 40 | } 41 | 42 | strong { 43 | font-size: 1em; 44 | } 45 | } 46 | } 47 | } 48 | 49 | .roiSpan { 50 | display: flex; 51 | flex-direction: row; 52 | // flex-wrap: wrap; 53 | justify-content: center; 54 | 55 | strong { 56 | text-align: right; 57 | white-space: nowrap; 58 | align-self: center; 59 | } 60 | 61 | p { 62 | align-self: center; 63 | .roi-percent { 64 | white-space: nowrap; 65 | } 66 | } 67 | 68 | 69 | 70 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build/release 2 | 3 | on: push 4 | 5 | jobs: 6 | release: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [macos-latest, windows-latest] 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v1 16 | 17 | - name: Install Node.js, NPM and Yarn 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 16 21 | 22 | - name: Install Snapcraft 23 | uses: samuelmeuli/action-snapcraft@v1 24 | # Only install Snapcraft on Ubuntu 25 | if: startsWith(matrix.os, 'ubuntu') 26 | with: 27 | # Log in to Snap Store 28 | snapcraft_token: ${{ secrets.snapcraft_token }} 29 | 30 | - name: Build/release Electron app 31 | uses: samuelmeuli/action-electron-builder@v1 32 | with: 33 | # GitHub token, automatically provided to the action 34 | # (No need to define this secret in the repo settings) 35 | github_token: ${{ secrets.github_token }} 36 | 37 | # If the commit is tagged with a version (e.g. "v1.0.0"), 38 | # release the app after building 39 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} 40 | mac_certs: ${{ secrets.mac_certs }} 41 | mac_certs_password: ${{ secrets.mac_certs_password }} 42 | env: 43 | # macOS notarization API key 44 | APPLEID: ${{ secrets.APPLEID }} 45 | APPLEIDPASS: ${{ secrets.APPLEIDPASS }} -------------------------------------------------------------------------------- /src/app/Pages/Stats/Views/SummaryStatistics.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAppSelector } from '@/app/redux/hooks'; 3 | 4 | // material UI components 5 | import {Grid} from '@mui/material'; 6 | 7 | // custom charts 8 | import { SummaryProfitByDay } from '@/app/Components/Charts/Area' 9 | import { PairPerformanceBar, BotPerformanceBar, ProfitByDay } from '@/app/Components/Charts/Bar'; 10 | 11 | const SummaryStatistics = () => { 12 | 13 | const { profitData, performanceData } = useAppSelector(state => state.threeCommas); 14 | const defaultCurrency = useAppSelector(state => state.config.currentProfile.general.defaultCurrency); 15 | 16 | 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | export default SummaryStatistics -------------------------------------------------------------------------------- /webpack.react.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: './src/renderer.tsx', 7 | target: 'electron-renderer', 8 | devtool: 'source-map', 9 | devServer: { 10 | contentBase: path.join(__dirname, 'dist/renderer.js'), 11 | compress: true, 12 | port: 9000 13 | }, 14 | resolve: { 15 | alias: { 16 | ['@']: path.resolve(__dirname, 'src'), 17 | ['#']: path.resolve(__dirname, '.') 18 | }, 19 | extensions: ['.tsx', '.ts', '.js'], 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.ts(x?)$/, 25 | include: /src/, 26 | use: [{ loader: 'ts-loader' }] 27 | }, 28 | { 29 | test: /\.js$/, 30 | exclude: /node_modules/, 31 | use: { 32 | loader: 'babel-loader', 33 | options: { 34 | presets: [[ 35 | '@babel/preset-env', { 36 | targets: { 37 | esmodules: true 38 | } 39 | }], 40 | '@babel/preset-react'] 41 | } 42 | } 43 | }, 44 | { 45 | test: /\.s[ac]ss$/i, 46 | use: [ 47 | 'style-loader', 48 | 'css-loader', 49 | 'sass-loader', 50 | ], 51 | } 52 | ] 53 | }, 54 | output: { 55 | path: __dirname + '/dist', 56 | filename: 'renderer.js' 57 | }, 58 | plugins: [ 59 | new HtmlWebpackPlugin({ 60 | template: './src/index.html' 61 | }) 62 | ] 63 | }; 64 | -------------------------------------------------------------------------------- /src/app/Pages/Settings/Settings.scss: -------------------------------------------------------------------------------- 1 | .settings-div{ 2 | margin-left: auto; 3 | margin-right: auto; 4 | 5 | width: 75%; 6 | min-width: 200px; 7 | 8 | padding: 5em; 9 | justify-content: space-between; 10 | text-align: left; 11 | 12 | // relative for the add profile dropdown. 13 | position: relative; 14 | 15 | } 16 | 17 | .settings-child { 18 | margin: 0 0 40px 0; 19 | } 20 | 21 | .subText{ 22 | font-weight: 300; 23 | font-size: .8em; 24 | margin: 0 0 1em 0; 25 | } 26 | 27 | .settingsButtonDiv{ 28 | justify-content: center; 29 | 30 | .deleteProfile{ 31 | background-color: var(--color-red); 32 | color: black !important; 33 | opacity: .9; 34 | } 35 | 36 | .deleteProfile:hover { 37 | background-color: var(--color-red); 38 | opacity: .8; 39 | } 40 | } 41 | 42 | .settingsButtonDiv button{ 43 | width: 200px; 44 | } 45 | 46 | .settings-right { 47 | margin-left: 15px; 48 | flex-basis: 50%; 49 | } 50 | 51 | .settings-left { 52 | margin-right: 15px; 53 | flex-basis: 50%; 54 | } 55 | 56 | #date-picker-inline { 57 | margin: 10px 58 | } 59 | 60 | .desktopPicker { 61 | margin: 10px; 62 | width: 100%; 63 | } 64 | 65 | .settings-dataGrid { 66 | flex-grow: 1; 67 | text-align: center; 68 | border: none; 69 | width: 50%; 70 | align-self: center; 71 | width: 60%; 72 | } 73 | 74 | .versionNumber { 75 | align-self: center; 76 | } 77 | 78 | .settings-datePicker{ 79 | .MuiSvgIcon-root { 80 | color: var(--color-text-lightbackground) !important; 81 | } 82 | } -------------------------------------------------------------------------------- /src/app/Features/CoinPriceHeader/CoinPriceHeader.scss: -------------------------------------------------------------------------------- 1 | .BtcPriceSpan { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | padding: .5em 0; 6 | margin-left: 60px; 7 | width: calc(100vw - 60px); 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: center; 11 | font-size: .95em !important; 12 | background-color: var(--color-background); 13 | z-index: 100; 14 | padding-right: 1em; 15 | box-shadow: 0 2px 4px -2px rgba(0,0,0,.2); 16 | 17 | .coinDiv { 18 | flex: 1; 19 | text-align: center; 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | } 24 | 25 | .coinHeaderButton { 26 | font-size: .95em !important; 27 | padding: 0; 28 | margin: 0; 29 | line-height: 0; 30 | 31 | .MuiButton-label { 32 | font-size: .9em !important; 33 | 34 | } 35 | } 36 | 37 | 38 | // add shadow 39 | } 40 | 41 | div.addCoinModal { 42 | background-color: var(--color-background); 43 | height: 350px; 44 | width: 300px; 45 | padding: 1em; 46 | position: relative; 47 | 48 | .closeIcon{ 49 | position: absolute; 50 | right: 2%; 51 | top: 2%; 52 | cursor: pointer; 53 | width: 25px; 54 | height: 25px; 55 | } 56 | 57 | .closeIcon:hover{ 58 | fill: var(--color-secondary-light25) 59 | } 60 | 61 | .selectedCoinDiv{ 62 | align-items: center; 63 | width: 100%; 64 | } 65 | 66 | .addCoinDiv { 67 | align-items: center; 68 | width: 100%; 69 | } 70 | 71 | 72 | .MuiAutocomplete-input { 73 | color: var(--color-text-lightbackground) 74 | } 75 | } -------------------------------------------------------------------------------- /src/app/Pages/Stats/Components/ViewRenderer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { RiskMonitor, SummaryStatistics, PerformanceMonitor } from '../Views/Index'; 3 | import { setStorageItem, getStorageItem, storageItem } from '@/app/Features/LocalStorage/LocalStorage'; 4 | const defaultNav = 'summary-stats'; 5 | const localStorageSortName = storageItem.navigation.statsPage 6 | 7 | const useViewRenderer = () => { 8 | 9 | const [currentView, changeView] = useState(defaultNav) 10 | 11 | const viewChanger = (newView: pageIds) => { 12 | 13 | const selectedNav = (newView != undefined) ? newView : defaultNav; 14 | changeView(selectedNav); 15 | setStorageItem(localStorageSortName, selectedNav) 16 | } 17 | 18 | useEffect(() => { 19 | const getSortFromStorage = getStorageItem(localStorageSortName); 20 | changeView(getSortFromStorage ?? defaultNav); 21 | }, []) 22 | 23 | return { 24 | currentView, 25 | viewChanger 26 | } 27 | 28 | 29 | } 30 | 31 | const ViewRenderer = ({ currentView }: { currentView: pageIds }) => { 32 | const currentViewRender = () => { 33 | let view = 34 | switch (currentView) { 35 | case 'risk-monitor': 36 | view = 37 | break; 38 | case 'performance-monitor': 39 | view = 40 | break; 41 | default: 42 | break; 43 | } 44 | 45 | return view; 46 | } 47 | return currentViewRender() 48 | } 49 | 50 | export { 51 | ViewRenderer, 52 | useViewRenderer 53 | } -------------------------------------------------------------------------------- /docs/developers/setup-a-dev-build/README.md: -------------------------------------------------------------------------------- 1 | # Setup a dev build 2 | 3 | A developers build will enable you to contribute to the project locally, make changes in real time, and test the code for yourself. Just follow the below steps. 4 | 5 | 1. Download the project locally 6 | 7 | ```text 8 | git clone https://github.com/coltoneshaw/3c-portfolio-manager.git 9 | ``` 10 | 11 | 2. Navigate into the folder you downloaded 12 | 13 | ```bash 14 | cd 3c-portfolio-manager 15 | ``` 16 | 17 | 3. Download the project dependencies. 18 | 19 | ```bash 20 | npm i --include=dev 21 | ``` 22 | 23 | 24 | If you experience issues with `node-gyp` when installing the dependencies run `pwd` or equivalent and ensure that you **do not** have any spaces in your path names. 25 | 26 | Invalid path name example - `/Desktop/my folder/3c-portfolio-manager` 27 | 28 | Valid path name example - `/Desktop/my_folder/3c-portfolio-manager` 29 | 30 | 4. Build webpack and sqlite3 31 | 32 | ```text 33 | npm run webpack 34 | npm run rebuild 35 | ``` 36 | 37 | These commands will take a few minutes as they build the webpack config and rebuild sqlite locally. 38 | 39 | 5. Start the dev server 40 | 41 | ```text 42 | npm run dev 43 | ``` 44 | 45 | This will start the development version of 3C portfolio manager in a new window for you to test with. As you make changes to the code the application will refresh. You may see errors for dev tools. This is expected until the full build is complete. Give it about a minute to finish. 46 | 47 | Note: If you make changes to the Electron main.ts / preload.ts file you may need to cancel the dev server, rebuild with `npm run rebuild`, and start up the dev server again. 48 | 49 | ```bash 50 | npm i --include=dev 51 | ``` 52 | 53 | -------------------------------------------------------------------------------- /src/app/Features/Changelog/changelogModal.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | div.changelogModal { 4 | background-color: var(--color-background); 5 | height: 60vh; 6 | width: 60vw; 7 | position: relative; 8 | 9 | .closeIcon{ 10 | position: absolute; 11 | right: 2%; 12 | top: 2%; 13 | cursor: pointer; 14 | width: 25px; 15 | height: 25px; 16 | } 17 | 18 | .closeIcon:hover{ 19 | fill: var(--color-secondary-light25) 20 | } 21 | h3{ 22 | margin: 0; 23 | padding: 1em; 24 | letter-spacing: .5px; 25 | text-transform: uppercase; 26 | } 27 | 28 | div.versionDiv { 29 | flex-basis: 25%; 30 | background-color: var(--color-primary); 31 | text-align: center; 32 | overflow-y: scroll; 33 | overflow-x: hidden; 34 | 35 | 36 | 37 | span.version { 38 | margin: 0 ; 39 | padding: 1em; 40 | letter-spacing: 1px; 41 | } 42 | 43 | span.version:hover { 44 | background-color: var(--color-primary-dark25) 45 | } 46 | 47 | span.version.active { 48 | background-color: var(--color-background); 49 | } 50 | 51 | 52 | 53 | } 54 | 55 | .changesDiv { 56 | flex-basis: 75%; 57 | padding-left: 1em; 58 | overflow-x: auto; 59 | 60 | 61 | h3{ 62 | padding-left: 0; 63 | } 64 | 65 | h4 { 66 | margin: 0 67 | } 68 | 69 | li { 70 | font-weight: 400; 71 | letter-spacing: .2px; 72 | } 73 | 74 | a { 75 | padding-bottom: .5em; 76 | color: var(--color-text-light) 77 | } 78 | } 79 | } 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/main/precheck.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@/main/Config/config"; 2 | import { TconfigValues } from "@/types/config"; 3 | import { checkOrMakeTables } from "./Database/initializeDatabase"; 4 | import log from 'electron-log'; 5 | import path from "path"; 6 | import fsExtra from 'fs-extra'; 7 | import { app } from "electron"; 8 | 9 | const appDataPath = app.getPath('userData'); 10 | const checkInvalidConfig = async (currentProfile: string | undefined | 'default', loadedConfig: TconfigValues) => { 11 | 12 | if(!currentProfile || currentProfile === 'default'){ 13 | try { 14 | const profileIds = Object.keys(loadedConfig.profiles); 15 | config.set('current', profileIds[0]) 16 | log.debug('Primary profile was undefined / default. Switching to ' + profileIds[0]) 17 | } catch (err) { 18 | log.error('Unable to convert config to use a new primary profile.', err) 19 | } 20 | 21 | } 22 | } 23 | 24 | const checkProfileDatabase = async (currentProfile: string) => { 25 | await checkOrMakeTables(currentProfile) 26 | } 27 | 28 | const checkDatabaseDirectory = async () => { 29 | 30 | const databaseDirExists = await fsExtra.pathExists(path.join(appDataPath, 'databases')) 31 | if(!databaseDirExists) { 32 | await fsExtra.mkdir(path.join(appDataPath, 'databases')); 33 | log.debug('Created the database directory') 34 | return; 35 | } 36 | log.debug('Database directory exists') 37 | } 38 | 39 | export const preloadCheck = async () => { 40 | const loadedConfig = config.store; 41 | const currentProfile = loadedConfig?.current; 42 | 43 | await checkInvalidConfig(currentProfile, loadedConfig) 44 | await checkDatabaseDirectory() 45 | await checkProfileDatabase(currentProfile) 46 | } -------------------------------------------------------------------------------- /src/app/Features/UpdateBanner/UpdateBanner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | 4 | import { openLink } from '@/utils/helperFunctions'; 5 | import { useAppDispatch, useAppSelector } from '@/app/redux/hooks'; 6 | import { updateBannerData, banner } from './redux/bannerSlice' 7 | import './UpdateBanner.scss'; 8 | 9 | import Close from '@mui/icons-material/Close'; 10 | let latestLink = 'https://github.com/coltoneshaw/3c-portfolio-manager/releases' 11 | 12 | const UpdateBanner = () => { 13 | 14 | const { show, message, type } = useAppSelector(state => state.banner) 15 | const dispatch = useAppDispatch() 16 | 17 | const returnBannerElement = (type: banner, message: string) => { 18 | if (type == 'updateVersion') { 19 | return (

There is a new update available! Click openLink(latestLink + '/tag/' + message)}>here to download {message}.

) 20 | } 21 | 22 | return

{message}

23 | } 24 | 25 | const renderBanner = () => { 26 | if (show) { 27 | return ( 28 |
35 | {returnBannerElement(type, message)} 36 | dispatch(updateBannerData({ show: false, message: '', type: '' }))} /> 37 |
38 | ) 39 | } 40 | } 41 | 42 | return ( 43 | <> 44 | {renderBanner()} 45 | 46 | ) 47 | } 48 | 49 | export default UpdateBanner; -------------------------------------------------------------------------------- /src/app/Pages/Stats/Views/RiskMonitor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAppSelector } from '@/app/redux/hooks'; 3 | 4 | // material UI components 5 | import { Grid } from '@mui/material'; 6 | 7 | // custom charts 8 | import { DealSoUtilizationBar, SoDistribution } from '@/app/Components/Charts/Bar/index' 9 | import SpeedometerDiv from '@/app/Pages/Stats/Components/SpeedometerDiv'; 10 | 11 | 12 | 13 | const RiskMonitor = () => { 14 | 15 | const { activeDeals, metricsData } = useAppSelector(state => state.threeCommas); 16 | const defaultCurrency = useAppSelector(state => state.config.currentProfile.general.defaultCurrency); 17 | 18 | return ( 19 | <> 20 |
21 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | {/* 33 | 34 | 35 | 36 | 37 | 38 | 39 | */} 40 |
41 | 42 |
43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | 50 | export default RiskMonitor -------------------------------------------------------------------------------- /docs/frequently-asked-questions/google-sheets.md: -------------------------------------------------------------------------------- 1 | # Google Sheets 2 | 3 | ### Why did we change from Google Sheets? 4 | 5 | If you find yourself asking "Why did you go from Google Sheets to an Electron app? I love sheets!" then read below! 6 | 7 | There were two reasons in the consideration of switching, the limitations/performance of sheet storage and the pain of end-users updating the sheet. 8 | 9 | #### Performance and storage limitations 10 | 11 | As you might know, Google Sheets limits your entire sheet to be about five million cells which means you cannot have all of your transaction data and for some people, you can only have a small subset of your recent deals. We went through a long process of pruning the data not needed and becoming critical of each column that we added. We even took the step of limiting the sheet to only about 5000 deals, however, this was still about 300k columns and a 2-6 minute data refresh the experience. 12 | 13 | **The updates...** 14 | 15 | How long have you used the sheet? Was it during the period when we had a new update every hour it seemed? So you may have experienced the pain updating was. It meant you had to... 16 | 17 | 1. Go copy the new sheet 18 | 2. Delete your old sheet 19 | 3. Regenerate your API keys 20 | 4. Put them in the new sheet 21 | 5. Hope the sheet took them without hitting an error that required private browsing... 22 | 6. Resync the entire sheet 23 | 7. Now you have your data again. 24 | 25 | Did that feel like a lot to you? Imagine doing it every time we release a new version. It's a pain, sometimes _even I_ wouldn't update to the new version since it wasn't worth it. Now with it being local, that means all you need to do is download the latest version, run the installer and you're done! Everything under the hood is stored directly on your computer. Which means it's also far safer. 26 | 27 | -------------------------------------------------------------------------------- /scripts/getReleaseCounts.js: -------------------------------------------------------------------------------- 1 | const fetch = require('electron-fetch').default 2 | 3 | 4 | const fetchVersions = async () => { 5 | let response = await fetch('https://api.github.com/repos/coltoneshaw/3c-portfolio-manager/releases', 6 | { 7 | method: 'GET', 8 | timeout: 30000, 9 | }); 10 | 11 | const data = await response.json() 12 | const downloadArray = {}; 13 | // const downloadsByRelease = [] 14 | let downloadsByOS = { 15 | 'Windows' : 0, 16 | 'Linux' : 0, 17 | 'Mac': 0, 18 | 'Total' : 0 19 | } 20 | 21 | for (release of data) { 22 | 23 | const {tag_name, assets} = release 24 | 25 | const downloads = {}; 26 | let releaseDownloads = 0; 27 | 28 | for (download of assets) { 29 | const {name, download_count} = download; 30 | if(name.includes('.exe')){ 31 | downloads['Windows'] = download_count 32 | downloadsByOS.Windows += download_count 33 | } else if(name.includes('.AppImage' || name.includes('.snap'))){ 34 | downloads['Linux'] = download_count 35 | downloadsByOS.Linux += download_count 36 | } else if(name.includes('.dmg')){ 37 | downloads['Mac'] = download_count 38 | downloadsByOS.Mac += download_count 39 | } 40 | 41 | releaseDownloads += +download_count 42 | } 43 | 44 | downloads['Total'] = releaseDownloads; 45 | downloadArray[tag_name] = downloads; 46 | downloadsByOS.Total += releaseDownloads 47 | // downloadArray.push({tag_name: downloads}) 48 | } 49 | 50 | console.log(downloadArray) 51 | // console.log(downloadsByRelease) 52 | console.log(downloadsByOS) 53 | 54 | } 55 | 56 | console.log(fetchVersions()) -------------------------------------------------------------------------------- /src/app/Components/Buttons/UpdateData.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import { Button } from '@mui/material'; 4 | import SyncIcon from '@mui/icons-material/Sync'; 5 | 6 | import { useAppSelector } from '@/app/redux/hooks'; 7 | import {updateAllData} from '@/app/redux/threeCommas/Actions' 8 | 9 | import { ToastNotifcations } from '@/app/Features/Index' 10 | 11 | interface Type_ButtonProps { 12 | style?: object, 13 | className?: string 14 | disabled?: boolean 15 | } 16 | const UpdateDataButton = ({ style, className}: Type_ButtonProps) => { 17 | const { threeCommas: {isSyncing}, config: {currentProfile}} = useAppSelector(state => state); 18 | 19 | const [spinning, updateSpinning] = useState(false) 20 | useEffect(() => updateSpinning(isSyncing), [isSyncing]) 21 | 22 | 23 | const [open, setOpen] = React.useState(false); 24 | 25 | const handleClick = () => { 26 | setOpen(true); 27 | }; 28 | 29 | const handleClose = (event: any, reason: string) => { 30 | if (reason === 'clickaway') { 31 | return; 32 | } 33 | 34 | setOpen(false); 35 | }; 36 | 37 | 38 | return ( 39 | <> 40 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export default UpdateDataButton; -------------------------------------------------------------------------------- /src/app/Pages/Settings/Components/WriteModeSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {FormControlLabel, FormGroup, Switch} from "@mui/material"; 3 | 4 | import { useAppSelector, useAppDispatch } from '@/app/redux/hooks'; 5 | import { configPaths } from "@/app/redux/globalFunctions"; 6 | import { updateEditProfileByPath } from "@/app/Pages/Settings/Redux/settingsSlice"; 7 | 8 | function WriteModeSettings () { 9 | 10 | const writeEnabled = useAppSelector(state => state.settings.editingProfile.writeEnabled); 11 | const dispatch = useAppDispatch() 12 | function toggleWriteEnabled() { 13 | dispatch(updateEditProfileByPath({ data: !writeEnabled, path: configPaths.writeEnabled })) 14 | 15 | } 16 | 17 | return (
18 |

Write mode:

19 |
20 |
21 |

22 | By activating the write mode, you will allow 3CPM to perform write operations on your 3CPM account. 3CPM will never perform any action without asking confirmation. 23 |

24 | 25 | 26 | } label="Enable write mode" /> 34 | 35 | 36 | 37 |
38 |
39 | 40 |
41 | ) 42 | } 43 | 44 | 45 | export default WriteModeSettings -------------------------------------------------------------------------------- /src/app/Components/DataTable/Table.scss: -------------------------------------------------------------------------------- 1 | .dataTableBase { 2 | overflow: auto; 3 | .table { 4 | border-spacing: 0; 5 | background-color: var(--color-background-light); 6 | color: var(--color-text-lightbackground); 7 | font-size: 0.9em; 8 | // display: block; 9 | .th, 10 | .td { 11 | margin: 0; 12 | // padding: 0.3rem 0.2rem 0.3rem 0.2rem; 13 | } 14 | 15 | .thead { 16 | position: sticky; 17 | top: 0; 18 | z-index: 100; 19 | padding: 0; 20 | // text-align: center !important; 21 | .th { 22 | font-weight: 700; 23 | } 24 | } 25 | 26 | .tbody { 27 | .dataTableInput { 28 | font-size: 0.875rem; 29 | padding: 0; 30 | margin: 0; 31 | border: 0; 32 | background-color: var(--color-background-light); 33 | color: var(--color-text-lightbackground); 34 | } 35 | 36 | .tr:nth-child(2n + 2) .td { 37 | background-color: var(--color-secondary-light87); 38 | 39 | .dataTableInput { 40 | background-color: var(--color-secondary-light87); 41 | } 42 | } 43 | .tr:hover .td { 44 | .dataTableInput { 45 | background-color: var(--color-secondary-light25); 46 | color: var(--color-text-darkbackground); 47 | } 48 | 49 | .MuiSwitch-thumb { 50 | background-color: darkgrey !important; 51 | } 52 | background-color: var(--color-secondary-light25); 53 | color: var(--color-text-darkbackground); 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/Pages/Stats/Components/StatFiltersDiv.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Type_Profile } from '@/types/config'; 3 | import { getLang } from '@/utils/helperFunctions'; 4 | const lang = getLang() 5 | 6 | const dateString = (currentProfile: Type_Profile) => { 7 | const date: undefined | number = currentProfile?.statSettings?.startDate; 8 | 9 | if (date != undefined) { 10 | const adjustedTime = date + ((new Date()).getTimezoneOffset() * 60000) 11 | const dateString = new Date(adjustedTime).toUTCString() 12 | return new Date(dateString).toLocaleString(lang, { month: '2-digit', day: '2-digit', year: 'numeric' }) 13 | } 14 | return "" 15 | } 16 | 17 | const returnAccountNames = (currentProfile: Type_Profile) => { 18 | 19 | const reservedFunds = currentProfile.statSettings.reservedFunds; 20 | return reservedFunds.length > 0 ? 21 | currentProfile.statSettings.reservedFunds.filter(account => account.is_enabled).map(account => account.account_name).join(', ') 22 | : 23 | "n/a"; 24 | } 25 | 26 | const returnCurrencyValues = (currentProfile: Type_Profile) => { 27 | const currencyValues: string[] | undefined = currentProfile.general.defaultCurrency 28 | return currencyValues != undefined && currencyValues.length > 0 ? 29 | currencyValues.join(', ') 30 | : 31 | "n/a"; 32 | } 33 | 34 | 35 | const StatFiltersDiv = ({ currentProfile }: { currentProfile: Type_Profile }) => { 36 | 37 | return ( 38 |
39 |

Account:
{returnAccountNames(currentProfile)}

40 |

Start Date:
{dateString(currentProfile)}

41 |

Filtered Currency:
{returnCurrencyValues(currentProfile)}

42 |
43 | ) 44 | } 45 | 46 | export default StatFiltersDiv; -------------------------------------------------------------------------------- /src/app/Components/icons/BotPlanner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const BotPlannerIcon = () => { 4 | return ( 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 33 | 34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | 46 | export default BotPlannerIcon; -------------------------------------------------------------------------------- /src/app/Features/3Commas/DataQueries/accounts.ts: -------------------------------------------------------------------------------- 1 | import { Type_Query_Accounts } from '@/types/3Commas' 2 | import { getFiltersQueryString } from '@/app/Features/3Commas/queryString'; 3 | import { Type_Profile } from '@/types/config' 4 | 5 | /** 6 | * 7 | * @param {string} defaultCurrency This is the default currency configured in settings and used as a filter 8 | * @returns 9 | */ 10 | const getAccountDataFunction = async (profileData: Type_Profile) => { 11 | const filtersQueryString = await getFiltersQueryString(profileData); 12 | const { currencyString, accountIdString, currentProfileID } = filtersQueryString; 13 | 14 | const query = ` 15 | SELECT 16 | * 17 | FROM 18 | accountData 19 | WHERE 20 | account_id IN ( ${accountIdString} ) 21 | and currency_code IN ( ${currencyString} ) 22 | ` 23 | let accountData: Type_Query_Accounts[] | [] = await window.ThreeCPM.Repository.Database.query(currentProfileID, query) 24 | 25 | // removed this since it seems redundant to the above query 26 | // .then((data: Type_Query_Accounts[]) => data.filter(row => defaultCurrency.includes(row.currency_code))) 27 | 28 | if (accountData == null || accountData.length > 0) { 29 | let on_ordersTotal = 0; 30 | let positionTotal = 0; 31 | 32 | for (const account of accountData) { 33 | const { on_orders, position } = account 34 | on_ordersTotal += on_orders; 35 | positionTotal += position; 36 | 37 | } 38 | return { 39 | accountData, 40 | balance: { 41 | on_orders: on_ordersTotal, 42 | position: positionTotal, 43 | } 44 | } 45 | } 46 | 47 | return { 48 | accountData: [], 49 | balance: { 50 | on_orders: 0, 51 | position: 0, 52 | } 53 | } 54 | } 55 | 56 | export { 57 | getAccountDataFunction 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/number_formatting.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 4 | * @param number Accepts a number or string, parses and returns 5 | * @param digits number of trailing digits to return. 6 | * @returns returns a number to 0 decimals and comma seperated 7 | */ 8 | const parseNumber = (number: number | string, digits:number = 0, activeDeals?: boolean) => { 9 | switch (typeof number) { 10 | case "number": // do nothing 11 | break 12 | case "string": 13 | number = parseInt(number) 14 | break 15 | default: 16 | number = 0 17 | } 18 | 19 | let numberFormatter:any = {'minimumFractionDigits': (digits > 4) ? 4 : digits, 'maximumFractionDigits': digits} 20 | 21 | if(activeDeals){ 22 | if(number >= 1000) numberFormatter = { 'minimumFractionDigits': 0, 'maximumFractionDigits': 0, "useGrouping": false} 23 | if(number >= 10) numberFormatter = { 'minimumFractionDigits': digits, 'maximumFractionDigits': digits, "useGrouping": false} 24 | if(number < 10) numberFormatter = { 'minimumFractionDigits': (digits > 4) ? digits : 4, 'maximumFractionDigits' : 8} 25 | } 26 | // console.log(digits) 27 | // if(maxSize && number >= 1) numberFormatter = { 'minimumSignificantDigits': digits , 'maximumSignificantDigits': digits, "useGrouping": false} 28 | // if(number < 1) numberFormatter = { 'minimumFractionDigits': (digits > 6) ? digits : 6 , 'maximumFractionDigits': (digits > 6) ? digits : 6 , "useGrouping": false} 29 | // if(maxSize && number < 1) numberFormatter = { 'minimumSignificantDigits': maxSize , 'maximumSignificantDigits': maxSize} 30 | 31 | return number.toLocaleString(undefined, numberFormatter) 32 | } 33 | 34 | /** 35 | * 36 | * @param num1 top number of fraction 37 | * @param num2 divisor - bottom number of the fraction 38 | * @returns a parsed string to fixed 0 with % 39 | */ 40 | const formatPercent = (num1:number , num2:number) => { 41 | return parseNumber ( ( (num1 / num2) * 100 ) , 0 ) + "%" 42 | } 43 | 44 | export { 45 | parseNumber, 46 | formatPercent 47 | } 48 | -------------------------------------------------------------------------------- /src/app/Components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './Sidebar.scss'; 3 | 4 | 5 | import { ActiveDealsIcon, TradingViewLogo, BotPlannerIcon, Coffee, Cog, PieChart } from '@/app/Components/icons/Index'; 6 | import MenuBookIcon from '@mui/icons-material/MenuBook'; 7 | import { SidebarNav, SidebarLink } from './Components'; 8 | import { ProfileSwitcher } from '@/app/Features/Profiles/Components/Index' 9 | 10 | import DisplaySwitcher from './DisplaySwitcher'; 11 | 12 | import {openLink} from '@/utils/helperFunctions' 13 | import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; 14 | 15 | 16 | /** 17 | * TODO: 18 | * - Move the settings / coffee cog into the display switcher. 19 | */ 20 | class Sidebar extends Component { 21 | 22 | render() { 23 | return ( 24 | 44 | ) 45 | } 46 | } 47 | 48 | export default Sidebar; -------------------------------------------------------------------------------- /src/main/3Commas/types/GridBots.ts: -------------------------------------------------------------------------------- 1 | export type GridBots = { 2 | "id": number, 3 | "account_id": number, 4 | "account_name": string, 5 | "is_enabled": boolean, 6 | "grids_quantity": string, 7 | "created_at": string //"2021-06-17T16:58:44.450Z", 8 | "updated_at": string //"2021-06-24T19:52:56.525Z", 9 | "strategy_type": "manual" | 'ai', 10 | "lower_price": string, 11 | "upper_price": string, 12 | "quantity_per_grid": string, 13 | "leverage_type": null | string, 14 | "leverage_custom_value": null | string, 15 | "name": string, 16 | "pair": string, 17 | "start_price": string, 18 | "grid_price_step": string, 19 | "current_profit": string, 20 | "current_profit_usd": string, 21 | "total_profits_count": string, 22 | "bought_volume": string, 23 | "sold_volume": string, 24 | "profit_percentage": string, 25 | "current_price": string, 26 | "investment_base_currency": string, 27 | "investment_quote_currency": string, 28 | "grid_lines": { 29 | "price": string, 30 | "side": null | string, 31 | "order_placed": boolean 32 | }[] 33 | } 34 | 35 | export type GridBotShow = GridBots & { 36 | editable: boolean 37 | } 38 | 39 | export type GridMarketOrders = { 40 | "grid_lines_orders": 41 | { 42 | "order_id": string, 43 | "order_type": "SELL" | 'BUY', 44 | "status_string": "Filled" | string, 45 | "created_at": string, 46 | "updated_at": string, 47 | "quantity": string, 48 | "quantity_remaining": string, 49 | "total": string, 50 | "rate": string, 51 | "average_price": string 52 | }[], 53 | 'balancing_orders' : any[] 54 | } 55 | 56 | export type GridBotProfits = { 57 | "grid_line_id": number, 58 | "profit": string, 59 | "usd_profit": string, 60 | "created_at": string 61 | } 62 | 63 | export type GridRequiredBalance = { 64 | "need_balancing": boolean, 65 | "necessary_quantities": { 66 | "quantity": string, 67 | "currency": string 68 | } 69 | } -------------------------------------------------------------------------------- /assets/assets/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 24 | 26 | 27 | 28 | 30 | 31 | 32 | 34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/app/Components/Charts/formatting.tsx: -------------------------------------------------------------------------------- 1 | import { formatCurrency, supportedCurrencies } from '@/utils/granularity' 2 | import type {defaultCurrency} from '@/types/config' 3 | import { dynamicSort } from '@/utils/helperFunctions'; 4 | import { Type_Bot_Performance_Metrics, Type_Pair_Performance_Metrics } from '@/types/3Commas'; 5 | 6 | 7 | const yAxisWidth = (defaultCurrency: defaultCurrency) => { 8 | 9 | const firstCurrency = defaultCurrency[0] ?? ['USD']; 10 | const yWidth = supportedCurrencies[firstCurrency].rounding * 10 ?? undefined 11 | return (yWidth < 50) ? undefined : yWidth 12 | } 13 | 14 | const currencyTickFormatter = (value: any, defaultCurrency: defaultCurrency) => { 15 | if(value === 0) return value 16 | return String(formatCurrency([defaultCurrency[0]], value).metric) 17 | } 18 | 19 | const currencyTooltipFormatter = (value: any, defaultCurrency: defaultCurrency) => { 20 | if(value === 0) return value 21 | 22 | const {metric, symbol} = formatCurrency([defaultCurrency[0]], value); 23 | return String(symbol + ' ' + metric) 24 | } 25 | 26 | const filterData = (data: Type_Bot_Performance_Metrics[] | Type_Pair_Performance_Metrics[], filter:string ) => { 27 | let newData = [...data] 28 | newData = newData.sort(dynamicSort('-total_profit')); 29 | const length = data.length; 30 | const fiftyPercent = length / 2 31 | const twentyPercent = length / 5 32 | 33 | if (filter === 'top20') { 34 | newData = newData.sort(dynamicSort('-total_profit')); 35 | return newData.filter( (bot, index) => index < twentyPercent) 36 | } else if (filter === 'top50') { 37 | newData = newData.sort(dynamicSort('-total_profit')); 38 | return newData.filter( (bot, index) => index < fiftyPercent) 39 | } else if (filter === 'bottom50') { 40 | newData = newData.sort(dynamicSort('total_profit')); 41 | return newData.filter( (bot, index) => index < fiftyPercent) 42 | } else if (filter === 'bottom20') { 43 | newData = newData.sort(dynamicSort('total_profit')); 44 | return newData.filter( (bot, index) => index < twentyPercent) 45 | } 46 | 47 | return newData; 48 | } 49 | 50 | export { 51 | yAxisWidth, 52 | currencyTickFormatter, 53 | currencyTooltipFormatter, 54 | filterData 55 | }; -------------------------------------------------------------------------------- /src/app/Components/Sidebar/svg/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 24 | 26 | 27 | 28 | 30 | 31 | 32 | 34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useState } from 'react'; 2 | 3 | import './App.global.scss'; 4 | import Sidebar from './Components/Sidebar/Sidebar'; 5 | import { HashRouter } from 'react-router-dom' 6 | import { MainWindow } from "@/app/Pages/Index" 7 | 8 | import { useThemeProvidor } from './Context/ThemeEngine'; 9 | 10 | import UpdateBanner from './Features/UpdateBanner/UpdateBanner'; 11 | 12 | 13 | import { useAppSelector, useAppDispatch } from '@/app/redux/hooks'; 14 | import { updateConfig } from '@/app/redux/config/configActions'; 15 | import { updateAllDataQuery } from './redux/threeCommas/Actions'; 16 | 17 | // @ts-ignore 18 | import { version } from '#/package.json'; 19 | import { updateBannerData } from '@/app/Features/UpdateBanner/redux/bannerSlice'; 20 | 21 | const App = () => { 22 | 23 | const themeEngine = useThemeProvidor(); 24 | const currentProfile = useAppSelector(state => state.config.currentProfile) 25 | const dispatch = useAppDispatch() 26 | const [profile, updateLocalProfile] = useState(() => currentProfile) 27 | const { styles } = themeEngine 28 | 29 | useEffect(() => { 30 | updateConfig(); 31 | }, []); 32 | 33 | useEffect(() => { 34 | window.ThreeCPM.Repository.Pm.versions() 35 | .then(versionData => { 36 | if (!versionData || !versionData[0]) return 37 | const currentVersion = versionData.filter((release: any) => !release.prerelease)[0] 38 | if ("v" + version != currentVersion.tag_name) { 39 | dispatch(updateBannerData({ show: true, message: currentVersion.tag_name, type: 'updateVersion' })) 40 | } 41 | }) 42 | }, []) 43 | 44 | useLayoutEffect(() => { 45 | if (currentProfile.id == profile.id) return 46 | if (currentProfile && currentProfile?.statSettings?.reservedFunds.filter(a => a.is_enabled).length > 0) { 47 | updateAllDataQuery(currentProfile, 'fullSync'); 48 | console.log('Changing to a new profile') 49 | updateLocalProfile(currentProfile) 50 | } 51 | 52 | }, [currentProfile]) 53 | 54 | 55 | return ( 56 | 57 |
58 | 59 | 60 | 61 |
62 |
63 | ) 64 | } 65 | 66 | export default App; 67 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | import type { supportedCurrencies } from '@/utils/granularity' 2 | 3 | 4 | 5 | export type defaultCurrency = (keyof typeof supportedCurrencies)[] | [] 6 | 7 | export interface Type_Profile { 8 | id: string 9 | name: string, 10 | apis: { 11 | threeC: { 12 | key: string, 13 | secret: string, 14 | mode: string, 15 | } 16 | }, 17 | general: { 18 | defaultCurrency: defaultCurrency 19 | globalLimit: number 20 | updated: boolean 21 | }, 22 | syncStatus: { 23 | deals: { 24 | lastSyncTime: number 25 | } 26 | }, 27 | statSettings: { 28 | startDate: number 29 | account_id: number[], 30 | reservedFunds: Type_ReservedFunds[] 31 | }, 32 | writeEnabled: boolean, 33 | } 34 | 35 | export type Type_NotificationsSettings = { 36 | enabled: boolean, 37 | summary: boolean, 38 | } 39 | 40 | export type Type_GlobalSettings = { 41 | notifications: Type_NotificationsSettings 42 | } 43 | 44 | 45 | 46 | 47 | export interface Type_ReservedFunds { 48 | id: number 49 | account_name: string 50 | reserved_funds: string | number 51 | is_enabled: boolean 52 | } 53 | 54 | export interface Type_ApiKeys { 55 | key: string 56 | secret: string 57 | } 58 | 59 | export type TconfigValues = { 60 | profiles: Record, 61 | current: string | 'default', 62 | globalSettings: Type_GlobalSettings, 63 | general: { 64 | version: string 65 | }, 66 | } 67 | 68 | export interface Type_ConfigContext { 69 | config: TconfigValues 70 | currentProfile: Type_Profile 71 | updateConfig: any 72 | setConfigBulk: any 73 | reset: any 74 | state: { 75 | accountID: number[] 76 | updateAccountID: any 77 | date: number 78 | updateDate: any 79 | currency: string[] 80 | updateCurrency: any 81 | updateApiData: any 82 | apiData: { key: string, secret: string, mode: string } 83 | reservedFunds: Type_ReservedFunds[], 84 | updateReservedFunds: any 85 | currentProfileId: string 86 | updateCurrentProfileId: any 87 | }, 88 | actions: { 89 | fetchAccountsForRequiredFunds: any 90 | } 91 | } -------------------------------------------------------------------------------- /src/renderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import App from '@/app/app'; 4 | import { ThemeProvider } from '@material-ui/core/styles'; 5 | import { ThemeEngine } from '@/app/Context/ThemeEngine' 6 | import DateAdapter from '@mui/lab/AdapterDateFns'; 7 | import LocalizationProvider from '@mui/lab/LocalizationProvider'; 8 | 9 | import store from '@/app/redux/store' 10 | import { Provider } from 'react-redux' 11 | 12 | import { 13 | ElectronAPIRepository, 14 | ElectronDealsRepository, 15 | ElectronDBRepository, 16 | ElectronConfigRepository, 17 | } from "@/app/Repositories/Impl/electron"; 18 | 19 | import BaseBinanceRepository from '@/app/Repositories/Impl/Binance'; 20 | import {BaseGeneralRepository, BasePmRepository} from '@/app/Repositories/Impl/General' 21 | 22 | import {Repository} from '@/app/Repositories/interfaces' 23 | 24 | 25 | interface ThreeCPMNS { 26 | Repository: Repository 27 | } 28 | 29 | declare global { 30 | interface Window { 31 | ThreeCPM: ThreeCPMNS; 32 | } 33 | } 34 | 35 | window.ThreeCPM = window.ThreeCPM || {}; 36 | const mainPreload = window.mainPreload 37 | 38 | let repo: Repository = { 39 | Deals: new ElectronDealsRepository(mainPreload), 40 | API: new ElectronAPIRepository(mainPreload), 41 | Config: new ElectronConfigRepository(mainPreload), 42 | Database: new ElectronDBRepository(mainPreload), 43 | Binance: new BaseBinanceRepository(mainPreload), 44 | General: new BaseGeneralRepository(mainPreload), 45 | Pm: new BasePmRepository(mainPreload) 46 | }; 47 | 48 | 49 | /* 50 | * For the future we could have something like this: 51 | */ 52 | // if (electrn) { 53 | // repo = { 54 | // API: new ElectronAPIRepository(electrn), 55 | // ... 56 | // } 57 | // } 58 | // ... 59 | 60 | 61 | // TODO: find a more react friendly way of making Repository accessible 62 | window.ThreeCPM.Repository = repo 63 | 64 | render( 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | , 78 | document.getElementById('root') 79 | 80 | ); 81 | 82 | 83 | -------------------------------------------------------------------------------- /src/app/Components/DataTable/FormatDeals.tsx: -------------------------------------------------------------------------------- 1 | import { Type_ActiveDeals } from "@/types/3Commas" 2 | import { calc_deviation } from '@/utils/formulas' 3 | import { parseNumber } from '@/utils/number_formatting'; 4 | 5 | 6 | const formatDeals = (activeDeals: Type_ActiveDeals[]) => { 7 | return activeDeals.map(deal => { 8 | const { max_safety_orders, 9 | active_manual_safety_orders, max_deal_funds, actual_usd_profit, 10 | actual_profit_percentage, pair, currency, 11 | safety_order_step_percentage, martingale_step_coefficient, 12 | current_price, take_profit_price, 13 | take_profit, base_order_volume, safety_order_volume, martingale_volume_coefficient, 14 | bought_volume, bought_amount, completed_safety_orders_count, completed_manual_safety_orders_count, trailing_deviation, trailing_enabled 15 | 16 | } = deal 17 | 18 | 19 | const safetyOrderString = (completed_manual_safety_orders_count > 0 || active_manual_safety_orders > 0) ? `${completed_safety_orders_count} + ${completed_manual_safety_orders_count} / ${max_safety_orders}` : `${completed_safety_orders_count} / ${max_safety_orders}` 20 | 21 | 22 | const ttp = (trailing_enabled) ? `(${trailing_deviation})` : '' 23 | 24 | return { 25 | ...deal, 26 | actual_usd_profit, 27 | actual_profit_percentage, 28 | current_price, 29 | take_profit_price, 30 | safetyOrderString, 31 | pair: pair + " / " + currency, 32 | // the below values need to be formatted to the same length across all the data 33 | max_deviation: calc_deviation(max_safety_orders, safety_order_step_percentage, martingale_step_coefficient), 34 | // in_profit: actual_usd_profit > 0, 35 | bot_settings: `TP: ${take_profit} ${ttp}, BO: ${base_order_volume}, SO: ${safety_order_volume}, SOS: ${safety_order_step_percentage}%, OS: ${martingale_volume_coefficient}, SS: ${martingale_step_coefficient}, MSTC: ${max_safety_orders}`, 36 | bought_volume: bought_volume ?? 0, 37 | // bought_amount: parseNumber( bought_amount, 5, true) + ' ' + pair, 38 | unrealized_profit: ( take_profit / 100 ) * bought_volume 39 | } 40 | }) 41 | } 42 | 43 | export default formatDeals; -------------------------------------------------------------------------------- /src/app/redux/globalFunctions.ts: -------------------------------------------------------------------------------- 1 | import { Type_Profile } from "@/types/config" 2 | 3 | export const configPaths = { 4 | apis: { 5 | threeC: { 6 | main: 'apis.threeC', 7 | key: 'apis.threeC.key', 8 | secret: 'apis.threeC.secret', 9 | mode: 'apis.threeC.mode' 10 | } 11 | }, 12 | syncStatus: { 13 | deals: { 14 | lastSyncTime: 'syncStatus.deals.lastSyncTime' 15 | } 16 | }, 17 | statSettings: { 18 | reservedFunds: 'statSettings.reservedFunds', 19 | startDate: 'statSettings.startDate', 20 | account_id: 'statSettings.account_id', 21 | }, 22 | name: 'name', 23 | writeEnabled: 'writeEnabled', 24 | general: { 25 | defaultCurrency: 'general.defaultCurrency' 26 | }, 27 | globalSettings: { 28 | notifications: { 29 | enabled: 'globalSettings.notifications.enabled', 30 | summary: 'globalSettings.notifications.summary', 31 | } 32 | } 33 | } 34 | 35 | 36 | export const updateProfileByPath = (data: any, profileData: Type_Profile, path: any) => { 37 | 38 | // let newProfile = Object.assign({}, { ...state.currentProfile }) 39 | switch (path) { 40 | case configPaths.apis.threeC.main: // update all the api data. 41 | profileData.apis.threeC = data 42 | break 43 | case configPaths.statSettings.reservedFunds: // update all the api data. 44 | profileData.statSettings.reservedFunds = data 45 | break 46 | case configPaths.name: // update all the api data. 47 | profileData.name = data 48 | break 49 | case configPaths.writeEnabled: // update all the api data. 50 | profileData.writeEnabled = data 51 | break 52 | case configPaths.general.defaultCurrency: // update all the currency data 53 | profileData.general.defaultCurrency = data 54 | break 55 | case configPaths.statSettings.startDate: // update all the api data. 56 | profileData.statSettings.startDate = data 57 | break 58 | case configPaths.syncStatus.deals.lastSyncTime: 59 | profileData.statSettings.startDate = data 60 | break 61 | default: 62 | break; 63 | } 64 | 65 | return profileData 66 | 67 | } -------------------------------------------------------------------------------- /src/app/Features/3Commas/3Commas.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | 4 | import { 5 | Type_Pair_By_Date, 6 | Type_UpdateFunction, 7 | } from '@/types/3Commas' 8 | import { Type_Profile } from '@/types/config' 9 | import { DateRange } from "@/types/Date"; 10 | 11 | import { getFiltersQueryString } from './queryString'; 12 | import { fetchPerformanceDataFunction, fetchDealDataFunction, getActiveDealsFunction, fetchSoData } from "./DataQueries/deals"; 13 | import { fetchBotPerformanceMetrics, botQuery } from "./DataQueries/bots"; 14 | import { getAccountDataFunction } from "./DataQueries/accounts"; 15 | 16 | // these queries use the deals database but are pairs only. Can probably combine this with the deals queries or create a new folder. 17 | import { fetchPairPerformanceMetrics, getSelectPairDataByDate } from "./DataQueries/pairs"; 18 | 19 | /** 20 | * @description This kicks off the update process that updates all 3Commas data within the database. 21 | * 22 | * @params - type 'autoSync' 23 | * @params {options} - option string 24 | */ 25 | const updateThreeCData = async (type: string, options: Type_UpdateFunction, profileData: Type_Profile) => { 26 | console.info({ options }) 27 | return await window.ThreeCPM.Repository.API.update(type, options, profileData); 28 | } 29 | 30 | export const initDate = (startString: number, oDate?: DateRange) => { 31 | let date = new DateRange() 32 | if (oDate) { 33 | date = { ...oDate } 34 | } 35 | 36 | if (date.from == null) { 37 | date.from = moment(startString).startOf("day").toDate() 38 | 39 | } 40 | 41 | if (date.to == null) { 42 | date.to = moment().endOf("day").toDate() 43 | } 44 | return date; 45 | } 46 | 47 | export const DateRangeToSQLString = (d: DateRange) => { 48 | let fromDateStr = moment.utc(d.from) 49 | .subtract(d.from?.getTimezoneOffset(), "minutes") 50 | .startOf("day") 51 | .toISOString() 52 | 53 | 54 | let toDateStr = moment.utc(d.to) 55 | .subtract(d.to?.getTimezoneOffset(), "minutes") 56 | .add(1, "days") 57 | .startOf("day") 58 | .toISOString() 59 | 60 | return [fromDateStr, toDateStr] 61 | } 62 | 63 | 64 | 65 | 66 | export { 67 | fetchDealDataFunction, 68 | fetchPerformanceDataFunction, 69 | getActiveDealsFunction, 70 | updateThreeCData, 71 | getAccountDataFunction, 72 | fetchBotPerformanceMetrics, 73 | fetchPairPerformanceMetrics, 74 | botQuery, 75 | getSelectPairDataByDate, 76 | getFiltersQueryString, 77 | fetchSoData 78 | } 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/app/Pages/ActiveDeals/Components/SubrowTabs/Orders.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { formatCurrency } from '@/utils/granularity' 3 | 4 | const dateFormatter = (dateString: string) => new Date(dateString).toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' }) 5 | 6 | function Orders({ row, ordersData }: any) { 7 | 8 | const formatCurrencyLocally = (value: number) => formatCurrency([row.original.from_currency], value).metric 9 | 10 | 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {ordersData.map((r: any) => ( 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | ) 41 | )} 42 | 43 |
SideOrder TypeStatusRate ({row.original.from_currency})Amount ({row.original.to_currency})Volume ({row.original.from_currency})CreatedUpdated
{r.order_type}{r.deal_order_type}{r.status_string} 33 | {r.order_type == "BUY" && (<>Desired: {formatCurrencyLocally(r.rate)}
Real: {formatCurrencyLocally(r.average_price)})} 34 | {r.order_type == "SELL" && (<>{formatCurrencyLocally(r.rate)})} 35 |
{formatCurrencyLocally(+r.quantity)}{(r.total) ? formatCurrencyLocally(r.total) : '-'}{dateFormatter(r.created_at)}{dateFormatter(r.updated_at)}
44 |
) 45 | } 46 | 47 | export default Orders -------------------------------------------------------------------------------- /src/app/Pages/ActiveDeals/Components/NotificationsSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '@/app/redux/hooks'; 2 | import { Checkbox, FormControlLabel } from '@mui/material'; 3 | import React, { useEffect, useState } from "react"; 4 | import { updateNotificationsSettingsGlobal } from '@/app/redux/config/configActions'; 5 | 6 | /** 7 | * 8 | * @returns Checkboxes for configuring the state of auto sync. 9 | */ 10 | const NotificationsSettings = () => { 11 | 12 | const { enabled: storeEnabled, summary: storeSummary } = useAppSelector(state => state.config.config.globalSettings.notifications); 13 | 14 | const [summary, setSummary] = useState(() => storeSummary) 15 | const [enabled, setEnabled] = useState(() => storeEnabled) 16 | 17 | useEffect(() => { 18 | setSummary(storeSummary) 19 | setEnabled(storeEnabled) 20 | }, [storeEnabled, storeSummary]); 21 | 22 | const changeSummary = (event: React.ChangeEvent) => { 23 | const checked = event.target.checked 24 | updateNotificationsSettingsGlobal({ summary: checked }) 25 | setSummary(checked) 26 | } 27 | 28 | const changeEnabled = (event: React.ChangeEvent) => { 29 | const checked = event.target.checked 30 | if(!checked) { 31 | updateNotificationsSettingsGlobal({ summary: false }) 32 | setSummary(false) 33 | } 34 | updateNotificationsSettingsGlobal({ enabled: checked }) 35 | setEnabled(checked) 36 | } 37 | 38 | return ( 39 |
40 | 50 | } 51 | label="Enable Notifications" 52 | 53 | /> 54 | 63 | } 64 | label="Summarize Notifications" 65 | /> 66 | 67 | 68 |
69 | ) 70 | } 71 | 72 | export default NotificationsSettings; 73 | -------------------------------------------------------------------------------- /src/app/redux/threeCommas/initialState.ts: -------------------------------------------------------------------------------- 1 | import { Type_ReservedFunds, Type_Profile } from '@/types/config' 2 | 3 | 4 | import type { 5 | Type_Query_PerfArray, 6 | Type_Query_bots, 7 | Type_ActiveDeals, 8 | Type_Query_Accounts, 9 | Type_MetricData, 10 | Type_Profit, 11 | Type_Bot_Performance_Metrics, 12 | Type_Performance_Metrics, 13 | Type_Pair_Performance_Metrics, 14 | Type_SyncOptions 15 | } from '@/types/3Commas' 16 | 17 | export {Type_MetricData, Type_Performance_Metrics, Type_ReservedFunds} 18 | // Define the initial state using that type 19 | export const initialState = { 20 | botData: [], 21 | profitData: [], 22 | activeDeals: [], 23 | performanceData: { pair_bot: [], bot: [], safety_order: [] }, 24 | balanceData: { on_orders: 0, position: 0 }, 25 | accountData: [], 26 | metricsData: { 27 | activeDealCount: 0, 28 | totalProfit_perf: 0, 29 | totalDeals: 0, 30 | boughtVolume: 0, 31 | averageDealHours: 0, 32 | averageDailyProfit: 0, 33 | totalBoughtVolume: 0, 34 | maxRisk: 0, 35 | totalProfit: 0, 36 | maxRiskPercent: 0, 37 | bankrollAvailable: 0, 38 | totalBankroll: 0, 39 | position: 0, 40 | on_orders: 0, 41 | totalInDeals: 0, 42 | availableBankroll: 0, 43 | reservedFundsTotal: 0, 44 | totalClosedDeals: 0, 45 | totalDealHours: 0, 46 | inactiveBotFunds: 0, 47 | totalMaxRisk: 0 48 | }, 49 | additionalData: [], 50 | isSyncing: false, 51 | isSyncingTime: 0, 52 | syncOptions: { 53 | time: 0, 54 | syncCount: 0 55 | }, 56 | autoRefresh: false, 57 | } 58 | 59 | export type typeString = 'botData' | 'profitData' | 'activeDeals' | 'performanceData' | 'metricsData' | 'accountData' | 'balanceData' 60 | 61 | export type Type_SyncData = { 62 | time: number, 63 | syncCount: number 64 | } 65 | 66 | export type setDataType = 67 | { type: 'botData', data: typeof initialState.botData } | 68 | { type: 'profitData', data: typeof initialState.profitData } | 69 | { type: 'activeDeals', data: typeof initialState.activeDeals } | 70 | { type: 'performanceData', data: typeof initialState.performanceData } | 71 | { type: 'balanceData', data: typeof initialState.balanceData } | 72 | { type: 'accountData', data: typeof initialState.accountData } | 73 | { type: 'metricsData', data: Type_MetricData } -------------------------------------------------------------------------------- /src/app/Pages/BotPlanner/Components/Risk.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | import { useAppSelector } from '@/app/redux/hooks'; 5 | import { Type_Query_bots } from '@/types/3Commas'; 6 | 7 | 8 | import { 9 | Card_EnabledBots, 10 | Card_DropCoverage, 11 | Card_MaxDca, 12 | Card_TotalBankRoll, 13 | Card_MaxRiskPercent 14 | } from '@/app/Components/Charts/DataCards'; 15 | 16 | 17 | // Need to import metric contexts here 18 | const Risk = ({ localBotData }: { localBotData: Type_Query_bots[] }) => { 19 | const { defaultCurrency } = useAppSelector(state => state.config.currentProfile.general); 20 | const { metricsData: {totalBankroll, totalBoughtVolume, position, reservedFundsTotal}} = useAppSelector(state => state.threeCommas); 21 | /** 22 | * Bankroll - sum, on_orders, position all added together. Needs to come from global state most likely. 23 | * risk - bank roll / total DCA risk 24 | * active bots - count of bots with enabled flagged. 25 | * DCA Max risk - sum of the max_bot_usage. 26 | */ 27 | 28 | const enabledDeals = localBotData.filter(bot => bot.is_enabled && !bot.hide) 29 | 30 | /** 31 | * TODO 32 | * - Can move these calculations on to the data card itself to clean up these functions. That would mean not every card gets a metric since most are calculated. 33 | */ 34 | let maxDCA = (enabledDeals.length > 0) ? enabledDeals.map(deal => deal.max_funds).reduce((sum, max) => sum + max) : 0; 35 | // const inactiveBotFunds = result.map(r => r.enabled_inactive_funds).reduce((sum, funds) => sum + funds ) ?? 0; 36 | 37 | let risk = (maxDCA / totalBankroll) * 100 38 | let botCount = localBotData.filter(deal => deal.is_enabled).length 39 | 40 | const sumDropCoverage = (enabledDeals.length > 0) ? enabledDeals.map(deal => (deal.maxCoveragePercent) ? deal.maxCoveragePercent : 0).reduce((sum, max) => sum + max) : 0; 41 | let dropCoverage = sumDropCoverage / enabledDeals.length 42 | 43 | 44 | 45 | return ( 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 | ) 55 | } 56 | 57 | export default Risk; -------------------------------------------------------------------------------- /src/app/Features/LocalStorage/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { tryParseJSON_ } from "@/utils/helperFunctions" 2 | 3 | 4 | const storageItem = { 5 | navigation: { 6 | homePage: 'homePage', // the home page the application navigates to 7 | statsPage: 'nav-statsPage' 8 | }, 9 | settings: { 10 | displayMode: 'displayMode', // the dark mode switcher. Values are 'lightMode' and 'darkMode', 11 | coinPriceArray: 'coinPriceArray' 12 | }, 13 | charts:{ 14 | pairByDateFilter: 'pairByDateFilter', 15 | BotPerformanceBubble:{ 16 | filter: 'filter-botPerformanceBubble'// filter for the bot bubble - values are 'all' , top20, top50, bottom50, bottom20 17 | }, 18 | DealPerformanceBubble:{ 19 | sort: 'sort-dealPerformanceBubble', // percentTotalProfit , number_of_deals , percentTotalVolume 20 | filter: 'filter-dealPerformanceBubble'// filter for the bot bubble - values are 'all' , top20, top50, bottom50, bottom20 21 | }, 22 | PairPerformanceBar: { 23 | sort: 'sort-pairPerformanceBar', // -total_profit, -bought_volume, -avg_deal_hours 24 | filter: 'filter-pairPerformanceBar', // all, top20, top50, bottom50, bottom20 25 | }, 26 | BotPerformanceBar: { 27 | sort: 'sort-BotPerformanceBar', // -total_profit, -bought_volume, -avg_deal_hours 28 | filter: 'filter-BotPerformanceBar', // all, top20, top50, bottom50, bottom20 29 | }, 30 | ProfitByDay: { 31 | sort: 'sort-ProfitByDay' //day , month, year 32 | } 33 | }, 34 | tables: { 35 | DealsTable: { 36 | sort: 'sort-DealsTable', // [ {id: 'value', desc: boolean}], 37 | columns: 'columns-DealsTable' // array of accessor ids from react-table 38 | }, 39 | BotPlanner: { 40 | sort: 'sort-BotPlanner', // [ {id: 'value', desc: boolean}], 41 | columns: 'columns-DealsTable'// array of accessor ids from react-table 42 | } 43 | } 44 | } 45 | 46 | const setStorageItem = (id:string, value:string | [] | object) => { 47 | 48 | if(typeof value === 'object') value = JSON.stringify(value) 49 | 50 | 51 | localStorage.setItem(id, value) 52 | 53 | } 54 | 55 | const getStorageItem = (id:string) => { 56 | 57 | const storageItem = localStorage.getItem(id) 58 | 59 | const parsed = (storageItem != undefined) ? tryParseJSON_(storageItem) : undefined 60 | 61 | if(parsed){ 62 | return parsed 63 | } 64 | 65 | return storageItem 66 | } 67 | 68 | 69 | 70 | export { 71 | storageItem, 72 | setStorageItem, 73 | getStorageItem 74 | } -------------------------------------------------------------------------------- /src/app/Components/Selectors/AccountSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Type_ReservedFunds } from '@/types/config'; 3 | 4 | import { 5 | FormControl, 6 | InputLabel, 7 | MenuItem, 8 | Select, 9 | ListItemText, 10 | Checkbox, 11 | } from '@mui/material'; 12 | 13 | type AccountSelector = { 14 | reservedFunds: Type_ReservedFunds[], 15 | updateAccounts: CallableFunction 16 | } 17 | const AccountSelector = ({reservedFunds, updateAccounts}:AccountSelector) => { 18 | const [selectedAccounts, updateSelectedAccounts] = useState(reservedFunds); 19 | const [selectedAccountIds, updateSelectedAccountIds] = useState(() => reservedFunds.filter(a => a.is_enabled).map(a => a.id)); 20 | const updateTempAccounts = (newAccounts: number[]) => { 21 | const selected = reservedFunds.filter(a => newAccounts.includes(a.id)) 22 | updateAccounts(selected) 23 | updateSelectedAccounts(selected) 24 | updateSelectedAccountIds(newAccounts) 25 | } 26 | 27 | const onChange = (e: any) => { 28 | updateTempAccounts([...e.target.value]) 29 | } 30 | 31 | 32 | const [open, setOpen] = useState(false); 33 | const handleClose = () => setOpen(false); 34 | const handleOpen = () => setOpen(true); 35 | 36 | return ( 37 | 38 | Accounts 39 | 65 | 66 | 67 | ) 68 | } 69 | 70 | export default AccountSelector; -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 3C Portfolio Manager 2 | 3 | The 3C Portfolio Manager is an essential add-on to your 3Commas experience. It enables you to manage your DCA bots with greater analytics, real-time alerting, and tons of additional features. It's a downloadable desktop application that's supported across Mac OS, Windows, and Linux operating systems. We are always expanding and adding new features! You may have seen this before as the 3C Portfolio Manager. That Google Sheet was the project that paved the way for the success of this application. 4 | 5 | ### Supported Operating Systems 6 | 7 | * Debian: 8 | * 10 - Seems to have a bug when attempting to run an Electron application 9 | * 11 - Works with the `.Appimage` 10 | * Chrome OS - Works with the `.Appimage` 11 | * macOS - M1 and Intel are fully supported 12 | * Windows 13 | * <10 - Untested, if it works let me know! 14 | * 10 - Fully supported 15 | * 11 - Fully supported - Thanks @Karizma! 16 | * Linux 17 | * Ubuntu - Fully Supported 18 | * Other distros are currently untested. 19 | 20 | ## Feedback or Bug Submission 21 | 22 | We welcome all feedback and bug reports as it helps us improve the project for everyone. You can submit these reports in two ways: 23 | 24 | 1. \(Preferred\) If you have a Github account do so on [the issues page](https://github.com/coltoneshaw/3c-portfolio-manager/issues) and select the right type of report. 25 | 2. If you do not have a GitHub account you can use our Google Form [here](https://forms.gle/EZeXuLcR8eosikkAA). 26 | 27 | If you have any issues don't hesitate to reach out to me on Discord @the\_okayest\_human\#1680 28 | 29 | ## Screenshots 30 | 31 | ### Dark Mode! 32 | 33 | ![Stats Dark Mode](https://user-images.githubusercontent.com/46071821/129786728-0b809352-4577-407f-9be2-0cbadf502e51.png) 34 | 35 | ### Active Deals 36 | 37 | ![Active Deals](https://user-images.githubusercontent.com/46071821/129786817-9baf215d-4dbe-4561-ae3f-5b9bfc33e8f4.png) 38 | 39 | ### Bot Planner 40 | 41 | ![Bot Planner](https://user-images.githubusercontent.com/46071821/129786825-b63830c5-f171-48af-a63c-29b90b451e50.png) 42 | 43 | ### Stats - Performance Monitor 44 | 45 | ![Stats - Performance Monitor](https://user-images.githubusercontent.com/46071821/129786830-923fa6af-1603-49ab-bbbe-f053c4d1f881.png) 46 | 47 | ### Stats - Risk Monitor 48 | 49 | ![Stats - Risk Monitor](https://user-images.githubusercontent.com/46071821/129786831-1394a978-7250-4c17-bea7-85869bfa10fa.png) 50 | 51 | ### Stats - Summary 52 | 53 | ![Stats - Summary](https://user-images.githubusercontent.com/46071821/129786832-10048284-7b3f-42bf-b3f3-287b3f87fcd0.png) 54 | 55 | ### Settings 56 | 57 | ![Settings Page](https://user-images.githubusercontent.com/46071821/129787149-8404a624-9b8b-4770-a8cf-2d0131498f3a.png) 58 | 59 | -------------------------------------------------------------------------------- /src/app/Pages/Stats/Stats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAppSelector } from '@/app/redux/hooks'; 3 | 4 | import './Stats.scss' 5 | import { Button, ButtonGroup } from '@mui/material'; 6 | 7 | import { UpdateDataButton } from '@/app/Components/Buttons/Index' 8 | import { RoiCards, ViewRenderer, useViewRenderer, StatFiltersDiv } from './Components/Index' 9 | 10 | 11 | 12 | const buttonElements:buttonElements = [ 13 | { 14 | name: 'Summary Statistics', 15 | id: 'summary-stats' 16 | }, 17 | { 18 | name: 'Risk Monitor', 19 | id: 'risk-monitor' 20 | }, 21 | { 22 | name: 'Performance Monitor', 23 | id: 'performance-monitor' 24 | } 25 | ] 26 | 27 | 28 | const StatsPage = () => { 29 | const { currentProfile } = useAppSelector(state => state.config); 30 | const { metricsData } = useAppSelector(state => state.threeCommas); 31 | 32 | const {currentView, viewChanger } = useViewRenderer() 33 | 34 | // // this feels redundant 35 | // const [reservedFunds, updateReservedFunds] = useState(() => currentProfile.statSettings.reservedFunds) 36 | // useEffect(() => { 37 | // if (currentProfile.statSettings.reservedFunds.length > 0) updateReservedFunds(currentProfile.statSettings.reservedFunds) 38 | // }, [currentProfile.statSettings.reservedFunds]) 39 | 40 | return ( 41 | <> 42 |
43 |
44 | {/* This needs to be it's own div to prevent the buttons from taking on the flex properties. */} 45 |
46 | 47 | { 48 | buttonElements.map(button => { 49 | if (button.id === currentView) return 50 | return 51 | }) 52 | } 53 | 54 | 55 |
56 |
57 | 58 |
59 | 60 | 61 | 62 | 63 | ) 64 | } 65 | 66 | 67 | 68 | export default StatsPage; -------------------------------------------------------------------------------- /src/types/preload.ts: -------------------------------------------------------------------------------- 1 | import { defaultCurrency, Type_Profile } from '@/types/config'; 2 | import { defaultConfig } from "@/utils/defaultConfig"; 3 | import type { UpdateDealRequest } from "@/main/3Commas/types/Deals"; 4 | import { Type_UpdateFunction } from '@/types/3Commas' 5 | import type { getDealOrders } from '@/main/3Commas/index'; 6 | import type {Type_GithubRelease} from '@/app/Repositories/Types/GithubRelease' 7 | import type {BinanceTicketPrice} from '@/app/Repositories/Types/Binance'; 8 | 9 | declare global { 10 | interface Window { 11 | mainPreload: mainPreload 12 | } 13 | } 14 | 15 | export interface config { 16 | get: (value: T) => Promise, 17 | profile: (type: 'create', newProfile: Type_Profile, profileId: string) => Promise, 18 | getProfile: (value: string, profileId: string) => Promise< Type_Profile | undefined >, 19 | reset: () => Promise, 20 | set: (key: string, value: any) => Promise, 21 | // setProfile: (key: string, value: any) => Promise, 22 | bulk: (changes: typeof defaultConfig) => Promise 23 | } 24 | 25 | 26 | export type tableNames = 'deals' | 'bots' | 'accountData' 27 | export interface database { 28 | query: (profileId:string, queryString: string) => Promise, 29 | update: (profileId:string, table: tableNames, updateData: object[]) => void, 30 | upsert: (profileId:string, table: tableNames, data: any[], id: string, updateColumn: string) => void, 31 | run: (profileId:string, query: string) => void, 32 | deleteAllData: (profileID?: string) => Promise 33 | } 34 | 35 | export interface api { 36 | update: (type: string, options: Type_UpdateFunction, profileData: Type_Profile) => Promise, 37 | updateBots: (profileData: Type_Profile) => Promise, 38 | getAccountData: (profileData?: Type_Profile, key?: string, secret?: string, mode?: string) => Promise<{ id: number, name: string }[]>, 39 | getDealOrders: (profileData: Type_Profile, dealID: number) => ReturnType, 40 | } 41 | 42 | export interface general { 43 | openLink: (link: string) => void 44 | } 45 | 46 | export interface binance { 47 | coinData: () => Promise< BinanceTicketPrice | false > 48 | } 49 | export interface pm { 50 | versions: () => Promise 51 | } 52 | 53 | interface mainPreload { 54 | deals: { 55 | update: (profileData: Type_Profile, deal: UpdateDealRequest) => Promise 56 | }, 57 | api: api, 58 | config: config, 59 | database: database, 60 | general: general, 61 | binance: binance, 62 | pm: pm 63 | }; 64 | 65 | export { 66 | mainPreload, 67 | Type_Profile, 68 | defaultConfig, 69 | UpdateDealRequest, 70 | Type_UpdateFunction, 71 | getDealOrders 72 | } -------------------------------------------------------------------------------- /src/app/Components/icons/Sun.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Sun = () => { 4 | 5 | return ( 6 | 7 | 8 | 9 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 23 | 24 | 25 | 27 | 28 | 29 | 31 | 32 | 33 | 35 | 36 | 37 | 39 | 40 | 41 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | export default Sun; -------------------------------------------------------------------------------- /src/main/3Commas/types/Bots.ts: -------------------------------------------------------------------------------- 1 | import {threeCommas_Api_Deals} from './Deals' 2 | 3 | export type Bots = { 4 | id: number, 5 | account_id: number, 6 | is_enabled: boolean, 7 | max_safety_orders: number, 8 | active_safety_orders_count: number, 9 | pairs: string[], 10 | strategy_list: {strategy: string}[], 11 | max_active_deals: number, 12 | active_deals_count: number, 13 | deletable?: boolean, 14 | created_at: string, 15 | updated_at: string, 16 | trailing_enabled: boolean, 17 | tsl_enabled: boolean, 18 | deal_start_delay_seconds: null | string, 19 | stop_loss_timeout_enabled: boolean, 20 | stop_loss_timeout_in_seconds: number, 21 | disable_after_deals_count: null | string, 22 | deals_counter: null | string, 23 | allowed_deals_on_same_pair: null | boolean, 24 | easy_form_supported: boolean, 25 | close_deals_timeout: null | string, 26 | url_secret: string, 27 | name: string, 28 | take_profit: string, 29 | base_order_volume: string, 30 | safety_order_volume: string, 31 | safety_order_step_percentage: string, 32 | take_profit_type: 'total', 33 | type: 'Bot::SingleBot' | 'Bot::MultiBot', 34 | martingale_volume_coefficient: string, 35 | martingale_step_coefficient: string, 36 | stop_loss_percentage: string, 37 | cooldown: string, 38 | btc_price_limit: string, 39 | strategy: 'long' | 'short', 40 | min_volume_btc_24h: string, 41 | profit_currency: 'quote_currency' | 'base_currency', 42 | min_price: null | 'quote_currency', 43 | max_price: null | 'quote_currency', 44 | stop_loss_type: 'stop_loss' | 'stop_loss_and_disable_bot' 45 | safety_order_volume_type: 'quote_currency' | 'percent', 46 | base_order_volume_type: 'quote_currency' | 'percent', 47 | account_name: string, 48 | trailing_deviation: string, 49 | finished_deals_profit_usd: string, 50 | finished_deals_count: string, 51 | leverage_type: 'not_specified' | string, 52 | leverage_custom_value: null, 53 | start_order_type: 'limit' | 'market', 54 | active_deals_usd_profit: string 55 | } 56 | 57 | export type ShowBot = Bots & { 58 | active_deals: threeCommas_Api_Deals[], 59 | bot_events?: {message: string, created_at:string}[] 60 | } 61 | 62 | 63 | export type GetBotsStats = { 64 | overall_stats: { 65 | USD?: string, 66 | BUSD?: string, 67 | USDT?: string, 68 | }, 69 | today_stats: { 70 | USD?: string, 71 | BUSD?: string, 72 | USDT?: string, 73 | }, 74 | profits_in_usd: { 75 | overall_usd_profit: number, 76 | today_usd_profit: number, 77 | active_deals_usd_profit: number, 78 | funds_locked_in_active_deals: number 79 | } 80 | } -------------------------------------------------------------------------------- /src/app/Components/Charts/Line/Components/PairSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useAppSelector } from '@/app/redux/hooks'; 3 | 4 | import { Select, InputLabel, FormControl, MenuItem, Checkbox, ListItemText, Input, SelectChangeEvent } from '@mui/material'; 5 | 6 | import { setStorageItem, getStorageItem, storageItem } from '@/app/Features/LocalStorage/LocalStorage'; 7 | 8 | 9 | type PairSelector = { 10 | pairs: { pair: string, opacity: number }[] 11 | pairFilters: string[] 12 | updatePairFilters: any 13 | } 14 | 15 | const PairSelector = ({ pairFilters, updatePairFilters, pairs }: PairSelector) => { 16 | const { currentProfile } = useAppSelector(state => state.config); 17 | 18 | // if any part of the current profile reserved funds changes then we clear the pairs 19 | useEffect(() => { 20 | updatePairFilters([]); 21 | setStorageItem(storageItem.charts.pairByDateFilter, []) 22 | 23 | }, [currentProfile.statSettings.reservedFunds]) 24 | 25 | const handleChange = (event: any) => { 26 | 27 | let filter = event.target.value; 28 | // preventing more than 8 items from showing at any given time. 29 | if (filter.length > 8) filter = filter.filter((pair: string, index: number) => index > 0) 30 | 31 | updatePairFilters([...filter]); 32 | setStorageItem(storageItem.charts.pairByDateFilter, [...filter]) 33 | }; 34 | 35 | const grid = (pairs.length <= 5) ? '1fr' : '1fr 1fr 1fr 1fr 1fr' 36 | 37 | 38 | 39 | return ( 40 |
41 | 42 | Show 43 | 44 | 73 | 74 | 75 |
76 | ) 77 | } 78 | 79 | export default PairSelector -------------------------------------------------------------------------------- /src/app/Pages/MainWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState , useLayoutEffect} from 'react'; 2 | import { Route, Redirect } from 'react-router-dom' 3 | import { 4 | BotPlannerPage, 5 | TradingViewPage, 6 | SettingsPage, 7 | StatsPage, 8 | ActiveDealsPage, 9 | DailyStats 10 | } from '@/app/Pages/Index' 11 | 12 | // @ts-ignore 13 | import { version } from '#/package.json'; 14 | 15 | import CoinPriceHeader from '@/app/Features/CoinPriceHeader/CoinPriceHeader'; 16 | import { useAppSelector } from '@/app/redux/hooks'; 17 | import { ChangelogModal } from '@/app/Features/Index'; 18 | import { getStorageItem, storageItem } from '@/app/Features/LocalStorage/LocalStorage'; 19 | 20 | const MainWindow = () => { 21 | 22 | const { currentProfile } = useAppSelector(state => state.config); 23 | 24 | 25 | const [homePage, updateHomePage] = useState('/activeDeals') 26 | 27 | useLayoutEffect(() => { 28 | if (currentProfile.apis.threeC.key !== "" && currentProfile.apis.threeC.secret !== "") { 29 | updateHomePage(getStorageItem(storageItem.navigation.homePage) ?? '/activeDeals') 30 | return 31 | } 32 | 33 | updateHomePage('/settings') 34 | }, [currentProfile.apis.threeC]) 35 | 36 | 37 | // changelog state responsible for opening / closing the changelog 38 | const [openChangelog, setOpenChangelog] = useState(false); 39 | 40 | const handleOpenChangelog = () => { 41 | setOpenChangelog(true); 42 | }; 43 | 44 | useEffect(() => { 45 | window.ThreeCPM.Repository.Config.get('general.version') 46 | .then((versionData: string) => { 47 | if (versionData == undefined || versionData != version) { 48 | handleOpenChangelog() 49 | 50 | // setting to false so this does not open again 51 | window.ThreeCPM.Repository.Config.set('general.version', version) 52 | } 53 | }) 54 | 55 | }, []) 56 | 57 | 58 | 59 | return ( 60 |
61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | } /> 69 | } /> 70 | } /> 71 | } /> 72 | } /> 73 | } /> 74 | 75 |
76 | 77 | ) 78 | } 79 | 80 | 81 | export default MainWindow; 82 | 83 | -------------------------------------------------------------------------------- /src/app/Repositories/Types/GithubRelease.ts: -------------------------------------------------------------------------------- 1 | type githubAuthor = { 2 | "login": string //"coltoneshaw", 3 | "id": number // 46071821, 4 | "node_id": string //"MDQ6VXNlcjQ2MDcxODIx", 5 | "avatar_url": number //"https://avatars.githubusercontent.com/u/46071821?v=4", 6 | "gravatar_id": string, 7 | "url": string // "https://api.github.com/users/coltoneshaw", 8 | "html_url": string //"https://github.com/coltoneshaw", 9 | "followers_url": string //"https://api.github.com/users/coltoneshaw/followers", 10 | "following_url": string // "https://api.github.com/users/coltoneshaw/following{/other_user}", 11 | "gists_url": string // "https://api.github.com/users/coltoneshaw/gists{/gist_id}", 12 | "starred_url": string // "https://api.github.com/users/coltoneshaw/starred{/owner}{/repo}", 13 | "subscriptions_url": string //"https://api.github.com/users/coltoneshaw/subscriptions", 14 | "organizations_url": string //"https://api.github.com/users/coltoneshaw/orgs", 15 | "repos_url": string // "https://api.github.com/users/coltoneshaw/repos", 16 | "events_url": string //"https://api.github.com/users/coltoneshaw/events{/privacy}", 17 | "received_events_url": string //"https://api.github.com/users/coltoneshaw/received_events", 18 | "type": 'user' // "User", 19 | "site_admin": boolean 20 | } 21 | 22 | type assets = { 23 | "url": string // "https://api.github.com/repos/coltoneshaw/3c-portfolio-manager/releases/assets/46530447", 24 | "id": number // 46530447, 25 | "node_id": string //"RA_kwDOFv81Ic4Cxf-P", 26 | "name": string // "3c-portfolio-manager-1.0.0-linux-x86_64.AppImage", 27 | "label": null | string, 28 | "uploader": githubAuthor, 29 | "content_type": string// "application/octet-stream", 30 | "state": string //"uploaded", 31 | "size": number // 93781957, 32 | "download_count": number // 34, 33 | "created_at": string //"2021-10-08T15:47:44Z", 34 | "updated_at": string //"2021-10-08T15:48:43Z", 35 | "browser_download_url": string //"https://github.com/coltoneshaw/3c-portfolio-manager/releases/download/v1.0.0/3c-portfolio-manager-1.0.0-linux-x86_64.AppImage" 36 | } 37 | 38 | export type Type_GithubRelease = { 39 | "url": string, 40 | "assets_url": string, 41 | "upload_url":string, 42 | "html_url": string, 43 | "id": number, 44 | "author": githubAuthor, 45 | "node_id": string // "RE_kwDOFv81Ic4DClhw", 46 | "tag_name": string // "v1.0.0", 47 | "target_commitish": string // "main", 48 | "name": string //"v1.0.0", 49 | "draft": false, 50 | "prerelease": false, 51 | "created_at": string //"2021-10-08T14:39:46Z", 52 | "published_at": string // "2021-10-08T14:40:27Z", 53 | "assets": assets[], 54 | "tarball_url": string // "https://api.github.com/repos/coltoneshaw/3c-portfolio-manager/tarball/v1.0.0", 55 | "zipball_url": string // "https://api.github.com/repos/coltoneshaw/3c-portfolio-manager/zipball/v1.0.0", 56 | "body": string // it's long. 57 | } -------------------------------------------------------------------------------- /src/app/Pages/ActiveDeals/Components/Subrow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import { useAppSelector } from "@/app/redux/hooks"; 4 | import Box from '@mui/material/Box'; 5 | import Tab from '@mui/material/Tab'; 6 | import TabContext from '@mui/lab/TabContext'; 7 | import TabList from '@mui/lab/TabList'; 8 | import TabPanel from '@mui/lab/TabPanel'; 9 | import { OrderTimeline, DCA, Orders } from './SubrowTabs/Index' 10 | import type { Type_MarketOrders } from '@/types/3Commas' 11 | 12 | 13 | function SubRows({ row, visibleColumns, ordersData, loading }: any) { 14 | if (loading) { 15 | return ( 16 |
17 |
18 |
19 | Loading... 20 |
21 |
22 | ); 23 | } 24 | 25 | const [activeTab, setActiveTab] = React.useState('timeline'); 26 | const handleChange = (event: any, newValue: string) => { 27 | setActiveTab(newValue); 28 | }; 29 | 30 | 31 | return ( 32 |
33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
54 |
55 | ); 56 | } 57 | 58 | function SubRowAsync({ row, visibleColumns }: any) { 59 | const [loading, setLoading] = useState(true); 60 | const [ordersData, setOrdersData] = useState([]); 61 | 62 | const { currentProfile } = useAppSelector(state => state.config); 63 | 64 | useEffect(() => { 65 | const getDealOrdersPromise = window.ThreeCPM.Repository.API.getDealOrders(currentProfile, row.original.id) 66 | 67 | Promise.all([getDealOrdersPromise]) 68 | .then(([getDealOrdersResult]) => { 69 | setOrdersData(getDealOrdersResult.reverse()); 70 | setLoading(false); 71 | }) 72 | 73 | }, []); 74 | 75 | return ( 76 | 82 | ); 83 | } 84 | 85 | export default SubRowAsync 86 | -------------------------------------------------------------------------------- /src/app/Pages/Stats/Stats.scss: -------------------------------------------------------------------------------- 1 | .tooltip { 2 | border-radius: .25em; 3 | background-color: var(--color-background); 4 | opacity: .9; 5 | color: var(--color-text-lightbackground); 6 | padding: 1em; 7 | text-align: left; 8 | margin-left: 2em; 9 | margin-right: 2em; 10 | } 11 | 12 | .tooltip h4, .tooltip p { 13 | padding: 0; 14 | margin: 0; 15 | } 16 | 17 | .tooltip h4{ 18 | text-align: center; 19 | padding-bottom: 1em; 20 | } 21 | 22 | .chartTitle{ 23 | text-align: center; 24 | padding: 0; 25 | margin: 0; 26 | font-weight: 300; 27 | } 28 | 29 | .speedDialContainer{ 30 | width: 100%; 31 | } 32 | 33 | .speedDials{ 34 | margin: auto; 35 | min-height: 300px; 36 | height: 400px; 37 | width: 30%; 38 | padding: 5% 39 | } 40 | 41 | 42 | .statHeaderRow{ 43 | padding-bottom: 15px; 44 | } 45 | 46 | .filters { 47 | font-weight: 400; 48 | font-size: .9em; 49 | flex-basis: 30%; 50 | align-items: center; 51 | justify-content: flex-end; 52 | 53 | } 54 | 55 | .menuButtons{ 56 | margin: auto; 57 | flex-basis: 70%; 58 | min-width: 730px; 59 | } 60 | 61 | 62 | @media only screen and (max-width: 1500px) { 63 | 64 | .statHeaderRow{ 65 | flex-wrap: wrap; 66 | } 67 | .filters{ 68 | flex-basis: 100%; 69 | justify-content: center; 70 | } 71 | 72 | .menuButtons{ 73 | flex-basis: 100%; 74 | justify-content: center; 75 | align-self: center; 76 | } 77 | 78 | } 79 | .filters p { 80 | padding: 0px 10px 0px 10px 81 | } 82 | 83 | .stat-chart { 84 | margin: 0; 85 | } 86 | 87 | .riskMonitorDiv { 88 | display: flex; 89 | flex-direction: column; 90 | width: 100%; 91 | height: 100%; 92 | 93 | .speedometerDiv { 94 | display: flex; 95 | flex-direction: row; 96 | justify-content: space-between; 97 | padding-bottom: 32px; 98 | width: 100%; 99 | } 100 | 101 | .chartDiv{ 102 | display: flex; 103 | flex-direction: column; 104 | width: 100%; 105 | } 106 | 107 | @media only screen and (min-width: 1550px) { 108 | flex-direction: row; 109 | flex-wrap: nowrap; 110 | 111 | .boxData { 112 | margin-bottom: 32px; 113 | } 114 | 115 | 116 | .speedometerDiv { 117 | flex-direction: column; 118 | width: 400px; 119 | padding-bottom: 0; 120 | 121 | .boxData:last-child { 122 | margin-bottom: 0; 123 | } 124 | } 125 | .chartDiv{ 126 | flex: 1; 127 | justify-content: space-between; 128 | 129 | .boxData{ 130 | height: 50%; 131 | margin-left: 32px; 132 | } 133 | 134 | .boxData:last-child { 135 | margin-bottom: 0; 136 | // margin-top: 32px; 137 | } 138 | 139 | } 140 | 141 | } 142 | } --------------------------------------------------------------------------------