TypeScript/Next.jsで作るFXトレードジャーナル構築記録 PH1-1

Prisma schema初版・画面設計・docker-compose初版

目次

はじめに(今回やること)

以前、PHP Laravelで作成し、現在も個人利用しているFXのトレード履歴管理Webアプリがあります。
一応実用はできているのですが、改善したい点や不具合が残ったままになっていました。

そこで今回、アプリを TypeScriptベースで再構築 することにしました。
新たに 「FXトレードジャーナル」 として作り直し、その過程をブログとして記録していきます。

本記事では、フェーズ1(設計・土台づくり) の内容をまとめます。

全体構想(今後の予定)
 フェーズ1:設計と土台づくり(目的:設計を固めて開発を始められる状態にする)
  1.テーブル設計・画面設計(今回)
  2.API設計
  3.ログイン画面 / ダッシュボード骨組み / docker-compose初版
  4.Prismaマイグレーション & 初期データ投入
  5.共通レイアウト・メニュー作成

 フェーズ2:取引一覧・詳細の実装
 フェーズ3:可視化・実用性アップ

既存システムの構成と再構築方針

現状は、以下の構成で運用しています。

  1. Laravel版Webアプリ(取引履歴表示)
  2. MT4インジケータ(取引履歴ファイルの定期出力)
  3. WinSCP + PowerShell + Windowsタスクスケジューラ(サーバーへ転送)

この構成自体は責務分離できていて悪くないため、新版でも基本的にはこの分解を活かす方針にしました。

再構築の方針(初期)

  • ②MT4インジケータ:当面そのまま利用
  • ③WinSCP + PowerShell:当面そのまま利用
  • ①Webアプリ部分:TypeScript + Docker で再構築(最優先)

まずは Web/API/DB/取込土台 を作り、後から必要であれば転送部分(PowerShell/WinSCP)を置き換える段階移行にします。

今回の技術選定

今回の再構築では、以下の構成を採用しました。

  • Next.js(TypeScript)
  • Prisma
  • PostgreSQL
  • Docker Compose

この構成にした理由

  • TypeScript を学習しながら、画面/API/バッチを同一言語で揃えやすい
  • Next.js は UI と API を一体で作りやすく、ポートフォリオにも向いている
  • Prisma は型安全にDBを扱いやすく、設計の見通しが良い
  • Docker Compose でローカル再現性を高めやすい

Prisma schema(初版)

初版のポイントは以下です。

  • Laravel版との互換性を意識し、取引の業務ユニークキー(broker_id + account_no + ticket_no)は維持
  • Prisma/TypeScript側で扱いやすいように内部ID(cuid)も付与
  • 既存の temp_equity は「上書き」ではなく、履歴型の EquitySnapshot に変更
  • 運用状況を追いやすくするため ImportJob / ImportJobFile を追加

まずはこのスキーマでテーブルを作り、フェーズ2でAPI実装を進める予定です。

schema.prisma

generator client {
provider = “prisma-client-js”
}

datasource db {
provider = “postgresql”
url = env(“DATABASE_URL”)
}

enum TradeSide {
BUY
SELL
OTHER
}

enum TradeStatus {
OPEN
CLOSED
}

enum ImportJobType {
TRADE_CSV
TRADE_IMAGE
EQUITY_SNAPSHOT
}

enum ImportJobStatus {
RUNNING
SUCCESS
PARTIAL
FAILED
}

enum BaselineType {
DAILY_0900
}

model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
role String? // 将来拡張用
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

accounts Account[]
tradeNotes TradeNote[]
}

model Broker {
id Int @id @default(autoincrement())
name String @unique
code String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

accounts Account[]
trades Trade[]
}

model Account {
id String @id @default(cuid())
userId String
brokerId Int
accountNo String
currency String @default(“JPY”)
note String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
broker Broker @relation(fields: [brokerId], references: [id], onDelete: Restrict)
snapshots EquitySnapshot[]

@@unique([userId, brokerId, accountNo], map: “uq_account_user_broker_accountno”)
@@index([brokerId, accountNo], map: “idx_account_broker_accountno”)
}

model Trade {
id String @id @default(cuid())
brokerId Int
accountNo String
ticketNo String

symbol String?
side TradeSide @default(OTHER)
status TradeStatus @default(CLOSED)
orderTypeRaw String? // MT4 raw value / stringを保持したい場合
lots Decimal? @db.Decimal(12, 4)

openPrice Decimal? @db.Decimal(18, 8)
closePrice Decimal? @db.Decimal(18, 8)
sl Decimal? @db.Decimal(18, 8)
tp Decimal? @db.Decimal(18, 8)

orderTime DateTime?
closeTime DateTime?

commission Decimal? @db.Decimal(18, 4)
swap Decimal? @db.Decimal(18, 4)
profit Decimal? @db.Decimal(18, 4)

mt4Comment String?
journalComment String? // アプリ側コメント(簡易版。詳細メモはtrade_notesへ)
importedAt DateTime @default(now())

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

broker Broker @relation(fields: [brokerId], references: [id], onDelete: Restrict)
images TradeImage[]
notes TradeNote[]
tradeTags TradeTag[]

@@unique([brokerId, accountNo, ticketNo], map: “uq_trade_broker_account_ticket”)
@@index([brokerId, accountNo, orderTime(sort: Desc)], map: “idx_trade_broker_account_ordertime”)
@@index([brokerId, accountNo, closeTime(sort: Desc)], map: “idx_trade_broker_account_closetime”)
@@index([symbol, closeTime(sort: Desc)], map: “idx_trade_symbol_closetime”)
@@index([status, closeTime(sort: Desc)], map: “idx_trade_status_closetime”)
@@index([side, closeTime(sort: Desc)], map: “idx_trade_side_closetime”)
}

model TradeImage {
id String @id @default(cuid())
tradeId String?
brokerId Int?
accountNo String?
ticketNo String?

storageProvider String @default(“local”) // local / s3 etc.
disk String? // local disk名など
path String
mime String?
size Int?
capturedAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

trade Trade? @relation(fields: [tradeId], references: [id], onDelete: SetNull)

@@index([tradeId], map: “idx_trade_image_tradeid”)
@@index([brokerId, accountNo, ticketNo], map: “idx_trade_image_broker_account_ticket”)
}

model TradeNote {
id String @id @default(cuid())
tradeId String
userId String
noteText String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

trade Trade @relation(fields: [tradeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([tradeId], map: “idx_trade_note_tradeid”)
@@index([userId], map: “idx_trade_note_userid”)
}

model Tag {
id Int @id @default(autoincrement())
name String @unique
color String?
createdAt DateTime @default(now())

tradeTags TradeTag[]
}

model TradeTag {
tradeId String
tagId Int
createdAt DateTime @default(now())

trade Trade @relation(fields: [tradeId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)

@@id([tradeId, tagId])
@@index([tagId], map: “idx_trade_tag_tagid”)
}

model EquitySnapshot {
id String @id @default(cuid())
accountId String
snapshotAt DateTime
snapshotDate DateTime // JST日付(00:00)などで保存
baselineType BaselineType
amount Decimal @db.Decimal(18, 4)
createdAt DateTime @default(now())

account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)

@@unique([accountId, baselineType, snapshotDate], map: “uq_snapshot_account_baseline_date”)
@@index([snapshotAt(sort: Desc)], map: “idx_snapshot_snapshotat”)
}

model ImportJob {
id String @id @default(cuid())
jobType ImportJobType
status ImportJobStatus
startedAt DateTime @default(now())
finishedAt DateTime?
targetPath String?
processedFiles Int @default(0)
importedCount Int @default(0)
updatedCount Int @default(0)
skippedCount Int @default(0)
failedCount Int @default(0)
message String?
createdBy String? // system / manual
createdAt DateTime @default(now())

files ImportJobFile[]
}

model ImportJobFile {
id String @id @default(cuid())
importJobId String
fileName String
filePath String?
status ImportJobStatus
message String?
createdAt DateTime @default(now())

importJob ImportJob @relation(fields: [importJobId], references: [id], onDelete: Cascade)

@@index([importJobId], map: “idx_import_job_file_jobid”)
}

画面一覧と項目定義(初版)

フェーズ1時点では、いきなり全画面を作るのではなく、先に必要な画面を整理しました。

フェーズ1で対象にする画面

  • ログイン画面
  • ダッシュボード画面
  • 取引一覧画面
  • 取引詳細画面
  • 損益カレンダー画面
  • インポート履歴画面(簡易)

特に今回は「後からAPI設計しやすいように、どの画面で何を表示/入力するか」を先に明確にしています。

docker-compose初版

今回はまず「開発環境を早く立ち上げる」ことを優先して、シンプルな構成にしました。

  • db(PostgreSQL)
  • web(Next.js)
  • worker(取込バッチ用)

初版での割り切り

  • cronコンテナはまだ用意しない(手動実行で確認)
  • web / worker は起動時に npm install 実行(開発用の簡易構成)
  • 画像保存は local volume(将来S3/MinIO対応を検討)

既存の WinSCP 転送先を data/inbox / data/img_inbox に合わせれば、既存運用を大きく変えずに移行を進められる想定です。

docker-compose.yml

version: “3.9”

services:
db:
image: postgres:16-alpine
container_name: fxjournal-db
restart: unless-stopped
environment:
POSTGRES_DB: fxjournal
POSTGRES_USER: fxuser
POSTGRES_PASSWORD: fxpass
TZ: Asia/Tokyo
ports:
– “5432:5432”
volumes:
– pgdata:/var/lib/postgresql/data
healthcheck:
test: [“CMD-SHELL”, “pg_isready -U fxuser -d fxjournal”]
interval: 10s
timeout: 5s
retries: 5

web:
build:
context: .
dockerfile: docker/Dockerfile
container_name: fxjournal-web
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: development
TZ: Asia/Tokyo
DATABASE_URL: postgresql://fxuser:fxpass@db:5432/fxjournal?schema=public
APP_TIMEZONE: Asia/Tokyo
MT4_TIMEZONE_OFFSET_HOURS: “2” # 例。運用に合わせて調整
IMPORT_CSV_DIR: /work/data/inbox
IMPORT_IMG_DIR: /work/data/img_inbox
STORAGE_DIR: /work/data/storage
ports:
– “3000:3000”
volumes:
– ./:/work
– /work/node_modules
– /work/.next
– ./data:/work/data
working_dir: /work
command: sh -c “npm install && npx prisma generate && npm run dev”

worker:
build:
context: .
dockerfile: docker/worker.Dockerfile
container_name: fxjournal-worker
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: development
TZ: Asia/Tokyo
DATABASE_URL: postgresql://fxuser:fxpass@db:5432/fxjournal?schema=public
APP_TIMEZONE: Asia/Tokyo
MT4_TIMEZONE_OFFSET_HOURS: “2”
IMPORT_CSV_DIR: /work/data/inbox
IMPORT_IMG_DIR: /work/data/img_inbox
STORAGE_DIR: /work/data/storage
volumes:
– ./:/work
– /work/node_modules
– ./data:/work/data
working_dir: /work
# 初版は常駐だけして、手動で docker compose exec worker … で実行
command: sh -c “npm install && npx prisma generate && tail -f /dev/null”

volumes:
pgdata:

まとめ

今回はフェーズ1として、FXトレードジャーナル再構築の土台になる以下を整理しました。

  • Prisma schema(初版)
  • 画面一覧と項目定義
  • docker-compose初版

いきなり実装に入ると後から修正コストが大きくなるため、まずは設計と土台を先に固める形にしました。
既存の Laravel版/MT4/WinSCP の運用を活かしながら段階移行できる形を意識しています。

次回は、今回の画面定義をもとに API設計(一覧取得・詳細取得・ダッシュボード集計・取込履歴)を整理していく予定です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次