Skip to content

Commit c8af41d

Browse files
committed
Update v1.0
1 parent 996e8b0 commit c8af41d

25 files changed

Lines changed: 5082 additions & 1 deletion

.github/workflows/deploy.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Deploy to GitHub Pages
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
pages: write
12+
id-token: write
13+
14+
concurrency:
15+
group: "pages"
16+
cancel-in-progress: false
17+
18+
jobs:
19+
build:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Setup pnpm
26+
uses: pnpm/action-setup@v4
27+
with:
28+
version: 9
29+
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: '20'
34+
cache: 'pnpm'
35+
36+
- name: Install dependencies
37+
run: pnpm install --frozen-lockfile
38+
39+
- name: Build
40+
run: pnpm run build
41+
env:
42+
GITHUB_PAGES: true
43+
44+
- name: Setup Pages
45+
uses: actions/configure-pages@v5
46+
47+
- name: Upload artifact
48+
uses: actions/upload-pages-artifact@v3
49+
with:
50+
path: './dist'
51+
52+
deploy:
53+
environment:
54+
name: github-pages
55+
url: ${{ steps.deployment.outputs.page_url }}
56+
runs-on: ubuntu-latest
57+
needs: build
58+
steps:
59+
- name: Deploy to GitHub Pages
60+
id: deployment
61+
uses: actions/deploy-pages@v4
62+

App.tsx

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import React, { useState, useEffect } from "react";
2+
import { parseJSONData } from "./utils/dataParser";
3+
import { AccountData, PositionData } from "./types";
4+
import SummaryCard from "./components/SummaryCard";
5+
import PositionsTable from "./components/PositionsTable";
6+
import PortfolioChart from "./components/PortfolioChart";
7+
import FileDropZone from "./components/FileDropZone";
8+
import { LayoutDashboard, Trash2, Maximize2, Minimize2 } from "lucide-react";
9+
10+
const APP_NAME = "Alloc";
11+
const STORAGE_KEY = `${APP_NAME}_json_data`;
12+
const FULL_WIDTH_STORAGE_KEY = `${APP_NAME}_full_width_preference`;
13+
14+
const App: React.FC = () => {
15+
const [account, setAccount] = useState<AccountData | null>(null);
16+
const [positions, setPositions] = useState<PositionData[]>([]);
17+
const [parseError, setParseError] = useState<string | null>(null);
18+
const [isLoadingFromStorage, setIsLoadingFromStorage] = useState(true);
19+
const [isFullWidth, setIsFullWidth] = useState<boolean>(false);
20+
21+
// Load data from localStorage on mount
22+
useEffect(() => {
23+
const loadFromStorage = () => {
24+
try {
25+
const storedJSON = localStorage.getItem(STORAGE_KEY);
26+
27+
if (storedJSON) {
28+
// Try to parse the stored JSON data
29+
const { account, positions } = parseJSONData(storedJSON);
30+
31+
if (account && positions && positions.length > 0) {
32+
setAccount(account);
33+
setPositions(positions);
34+
} else {
35+
// Stored data is invalid, clear it
36+
localStorage.removeItem(STORAGE_KEY);
37+
}
38+
}
39+
40+
// Load full-width preference
41+
const fullWidthPreference = localStorage.getItem(
42+
FULL_WIDTH_STORAGE_KEY
43+
);
44+
if (fullWidthPreference !== null) {
45+
setIsFullWidth(fullWidthPreference === "true");
46+
}
47+
} catch (e) {
48+
console.error("Failed to load data from localStorage", e);
49+
// Clear corrupted data
50+
localStorage.removeItem(STORAGE_KEY);
51+
} finally {
52+
setIsLoadingFromStorage(false);
53+
}
54+
};
55+
56+
loadFromStorage();
57+
}, []);
58+
59+
const handleFileLoaded = (jsonContent: string) => {
60+
try {
61+
setParseError(null);
62+
63+
const { account, positions } = parseJSONData(jsonContent);
64+
65+
if (!account) {
66+
throw new Error(
67+
"Failed to parse account data. Please check the file format."
68+
);
69+
}
70+
71+
if (!positions || positions.length === 0) {
72+
throw new Error(
73+
"Failed to parse positions data or file is empty. Please check the file format."
74+
);
75+
}
76+
77+
// Save to localStorage
78+
localStorage.setItem(STORAGE_KEY, jsonContent);
79+
80+
setAccount(account);
81+
setPositions(positions);
82+
} catch (e) {
83+
const errorMessage =
84+
e instanceof Error
85+
? e.message
86+
: "Failed to parse data. Please check your JSON file.";
87+
setParseError(errorMessage);
88+
console.error("Failed to parse data", e);
89+
}
90+
};
91+
92+
const handleError = (error: string) => {
93+
setParseError(error);
94+
};
95+
96+
const handleReset = () => {
97+
setAccount(null);
98+
setPositions([]);
99+
setParseError(null);
100+
// Clear localStorage
101+
localStorage.removeItem(STORAGE_KEY);
102+
};
103+
104+
const handleToggleFullWidth = () => {
105+
const newValue = !isFullWidth;
106+
setIsFullWidth(newValue);
107+
localStorage.setItem(FULL_WIDTH_STORAGE_KEY, String(newValue));
108+
};
109+
110+
// Show loading state while checking localStorage
111+
if (isLoadingFromStorage) {
112+
return (
113+
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
114+
<div className="text-blue-500 animate-pulse text-xl font-semibold">
115+
Loading Portfolio Data...
116+
</div>
117+
</div>
118+
);
119+
}
120+
121+
// Show drag and drop interface if no account data is loaded
122+
if (!account) {
123+
return (
124+
<FileDropZone
125+
onFileLoaded={handleFileLoaded}
126+
onError={handleError}
127+
parseError={parseError}
128+
/>
129+
);
130+
}
131+
132+
// Calculate some aggregate totals that might be missing or useful
133+
const totalDailyPL = positions.reduce(
134+
(acc, curr) => acc + curr.today_pl_val,
135+
0
136+
);
137+
const totalUnrealizedPL = positions.reduce(
138+
(acc, curr) => acc + curr.unrealized_pl,
139+
0
140+
);
141+
142+
return (
143+
<div className="min-h-screen bg-slate-900 pb-12">
144+
{/* Header */}
145+
<nav className="bg-slate-800 border-b border-slate-700 sticky top-0 z-50">
146+
<div
147+
className={`${
148+
isFullWidth ? "max-w-full" : "max-w-7xl"
149+
} mx-auto px-4 sm:px-6 lg:px-8`}
150+
>
151+
<div className="flex justify-between h-16 items-center">
152+
<div className="flex items-center gap-2">
153+
<LayoutDashboard className="h-6 w-6 text-blue-500" />
154+
<span className="font-bold text-xl text-white tracking-tight">
155+
<span className="text-blue-500">Alloc</span>{" "}
156+
Dashboard
157+
</span>
158+
</div>
159+
<div className="flex items-center gap-4 text-sm">
160+
<button
161+
onClick={handleToggleFullWidth}
162+
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 hover:text-white bg-slate-700/50 hover:bg-slate-700 rounded-lg transition-colors"
163+
title={
164+
isFullWidth
165+
? "Use Constrained Width"
166+
: "Use Full Width"
167+
}
168+
>
169+
{isFullWidth ? (
170+
<Minimize2 className="h-4 w-4" />
171+
) : (
172+
<Maximize2 className="h-4 w-4" />
173+
)}
174+
<span className="hidden sm:inline">
175+
{isFullWidth ? "Constrained" : "Full Width"}
176+
</span>
177+
</button>
178+
<button
179+
onClick={handleReset}
180+
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 hover:text-white bg-slate-700/50 hover:bg-slate-700 rounded-lg transition-colors"
181+
title="Clear Data"
182+
>
183+
<Trash2 className="h-4 w-4" />
184+
<span className="hidden sm:inline">
185+
Clear Data
186+
</span>
187+
</button>
188+
</div>
189+
</div>
190+
</div>
191+
</nav>
192+
193+
<main
194+
className={`${
195+
isFullWidth ? "max-w-full" : "max-w-7xl"
196+
} mx-auto px-4 sm:px-6 lg:px-8 pt-8`}
197+
>
198+
{/* Account Summary */}
199+
<section className="mb-2">
200+
{/* <div className="flex justify-between items-end mb-4">
201+
<h1 className="text-2xl font-bold text-white">
202+
Overview
203+
</h1>
204+
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-1 rounded border border-slate-700">
205+
Currency: {account.currency}
206+
</span>
207+
</div> */}
208+
<SummaryCard account={account} />
209+
</section>
210+
211+
{/* Charts Section */}
212+
<section className="mb-8">
213+
<PortfolioChart positions={positions} />
214+
</section>
215+
216+
{/* Positions Table */}
217+
<section>
218+
<PositionsTable positions={positions} />
219+
</section>
220+
</main>
221+
</div>
222+
);
223+
};
224+
225+
export default App;

README.md

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,76 @@
1-
# Alloc
1+
# Alloc
2+
3+
Stock Portfolio Visualization made for [FUTU/Moomoo](https://www.moomoo.com/) users.
4+
5+
## Read Me First
6+
7+
This is a web app that visualizes your stock portfolio data purely from the browser-side (client-side, locally). It does not require any server-side processing or database.
8+
9+
[FUTU/Moomoo](https://www.moomoo.com/) does not provide proper scope-managed API for third-party apps. Thus, for 100% security and privacy, this app does not ask for any account credentials or tokens. Instead, it relies on you to export your portfolio data via moomoo's official API yourself. We provide a simple script to do that and the script doesn't handle any part of the authentication.
10+
11+
Read the following section for step-by-step instructions.
12+
13+
## Exporting Portfolio Data
14+
15+
### Prerequisites
16+
17+
- You need to be a [FUTU/Moomoo](https://www.moomoo.com/) user with an active account, anywhere in the world (incl. US, HK, SG, etc.).
18+
19+
### Step 1: Setup Moomoo API Client Locally
20+
21+
1. Download the latest [moomoo OpenD GUI Client](https://www.moomoo.com/download/OpenAPI?_ga=2.228072795.755266222.1765942756-812069572.1761661575&_gac=1.190289369.1765944929.Cj0KCQiAo4TKBhDRARIsAGW29bdGCT3yyW5G99CEqHuhi0rb6N6xPiw4oVzmKEURl0xcbzfzn8wOFo8aAmzkEALw_wcB&chain_id=KXCd6VRrpQ6J-S.1kk4cl5&global_content=%7B%22promote_id%22%3A1010,%22sub_promote_id%22%3A344,%22f%22%3A%22mm%2Fsg%2Fsupport%2Ftopic3_441%22%7D) to your local machine.
22+
- Choose `moomoo OpenAPI` > `moomoo OpenD` > Latest Version.
23+
- Note that you are downloading `moomoo OpenD` instead of `moomoo API`.
24+
- After downloading, run the installer and follow the instructions to install it.
25+
2. Run the `moomoo OpenD` client and login to your [FUTU/Moomoo](https://www.moomoo.com/) account.
26+
- Use all default settings and login with your account credentials. By default, it should have parameters like:
27+
- `IP`: `127.0.0.1`
28+
- `Port`: `11111`
29+
- When you login for the first time on a new device, it will ask you to authenticate via 2FA. Save your login details and check the automatic login checkbox will allow you to login smoothly next time.
30+
- Once you are logged in, leave it running in the background.
31+
32+
### Step 2: Run the Export Script
33+
34+
1. Clone this repository:
35+
36+
```bash
37+
git clone https://github.com/mxshell/Alloc.git
38+
cd Alloc
39+
```
40+
41+
2. Set up the Python environment:
42+
43+
```bash
44+
cd python
45+
uv sync
46+
```
47+
48+
3. Run the export script:
49+
50+
```bash
51+
uv run moomoo_export.py
52+
```
53+
54+
- After the script is done, you will find the exported data (a single JSON file) in the `python` directory.
55+
- Once you have the exported data, you can close/terminate the `moomoo OpenD` client.
56+
57+
## Run `Alloc` Web App Locally
58+
59+
**Environment:**
60+
61+
- `Node.js`
62+
- `pnpm` (optional, but recommended)
63+
64+
**Commands:**
65+
66+
1. Install app dependencies:
67+
`pnpm install` or `npm install`
68+
2. Run the app:
69+
`pnpm run dev` or `npm run dev`
70+
71+
## Using `Alloc` Web App
72+
73+
1. Open the `Alloc` web app in your browser.
74+
- Publicly hosted at [https://mxshell.dev/Alloc](https://mxshell.dev/Alloc/)
75+
- or use your locally hosted app by opening [http://localhost:3000](http://localhost:3000) in your browser.
76+
2. Simply drag and drop the exported JSON file into the app.

0 commit comments

Comments
 (0)