diff --git a/.gitignore b/.gitignore
index b66118c..67c3c05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,5 @@
# Deploy / local
*.tar.gz
+data/mobile-releases/*.apk
+scripts/deploy-target.local.env
diff --git a/app/.env.example b/app/.env.example
new file mode 100644
index 0000000..dae98f2
--- /dev/null
+++ b/app/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=http://localhost:8080/api
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000..3778e07
--- /dev/null
+++ b/app/README.md
@@ -0,0 +1,47 @@
+# GoldenChart Mobile App
+
+Capacitor + React hybrid app for virtual trading, strategy editor, notifications, and settings.
+
+## Setup
+
+```bash
+# From repo root
+npm install
+npm run dev:app
+```
+
+## Native build
+
+```bash
+npm run build:app
+npm run cap:sync
+npm run cap:android # or cap:ios
+```
+
+## exdev.co.kr APK 배포
+
+소스는 `git push origin main` (Jenkins). APK만 로컬 빌드 후 업로드:
+
+```bash
+./scripts/upload-android-apk.sh --build --version 1.0.0
+```
+
+`app/.env` 예: `VITE_API_BASE_URL=http://exdev.co.kr/api`
+
+자세한 내용: [`data/mobile-releases/README.md`](../data/mobile-releases/README.md)
+
+## Environment
+
+Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`.
+
+## FCM (Firebase)
+
+- Android: `android/app/google-services.json`
+- iOS: `ios/App/GoogleService-Info.plist` + Push Notifications capability
+- Backend: `FIREBASE_ENABLED=true` + service account JSON
+
+## Structure
+
+- `src/screens/` — mobile UI (virtual, strategy, notifications, settings)
+- `../packages/shared/` — API client + Capacitor storage
+- `../frontend/src/` — shared business logic (hooks, utils) via `@frontend` alias
diff --git a/app/android/.gitignore b/app/android/.gitignore
new file mode 100644
index 0000000..48354a3
--- /dev/null
+++ b/app/android/.gitignore
@@ -0,0 +1,101 @@
+# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
+
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/modules.xml
+# Comment next line if keeping position of elements in Navigation Editor is relevant for you
+.idea/navEditor.xml
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+# Cordova plugins for Capacitor
+capacitor-cordova-android-plugins
+
+# Copied web assets
+app/src/main/assets/public
+
+# Generated Config files
+app/src/main/assets/capacitor.config.json
+app/src/main/assets/capacitor.plugins.json
+app/src/main/res/xml/config.xml
diff --git a/app/android/app/.gitignore b/app/android/app/.gitignore
new file mode 100644
index 0000000..043df80
--- /dev/null
+++ b/app/android/app/.gitignore
@@ -0,0 +1,2 @@
+/build/*
+!/build/.npmkeep
diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle
new file mode 100644
index 0000000..ffaab5f
--- /dev/null
+++ b/app/android/app/build.gradle
@@ -0,0 +1,54 @@
+apply plugin: 'com.android.application'
+
+android {
+ namespace "com.goldenchart.app"
+ compileSdk rootProject.ext.compileSdkVersion
+ defaultConfig {
+ applicationId "com.goldenchart.app"
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 1
+ versionName "1.0"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ aaptOptions {
+ // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+ // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
+ ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
+ }
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+repositories {
+ flatDir{
+ dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
+ }
+}
+
+dependencies {
+ implementation fileTree(include: ['*.jar'], dir: 'libs')
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+ implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
+ implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
+ implementation project(':capacitor-android')
+ testImplementation "junit:junit:$junitVersion"
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
+ implementation project(':capacitor-cordova-android-plugins')
+}
+
+apply from: 'capacitor.build.gradle'
+
+try {
+ def servicesJSON = file('google-services.json')
+ if (servicesJSON.text) {
+ apply plugin: 'com.google.gms.google-services'
+ }
+} catch(Exception e) {
+ logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
+}
diff --git a/app/android/app/capacitor.build.gradle b/app/android/app/capacitor.build.gradle
new file mode 100644
index 0000000..5664116
--- /dev/null
+++ b/app/android/app/capacitor.build.gradle
@@ -0,0 +1,24 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+
+android {
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_21
+ targetCompatibility JavaVersion.VERSION_21
+ }
+}
+
+apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
+dependencies {
+ implementation project(':capacitor-app')
+ implementation project(':capacitor-haptics')
+ implementation project(':capacitor-preferences')
+ implementation project(':capacitor-push-notifications')
+ implementation project(':capacitor-splash-screen')
+ implementation project(':capacitor-status-bar')
+
+}
+
+
+if (hasProperty('postBuildExtras')) {
+ postBuildExtras()
+}
diff --git a/app/android/app/proguard-rules.pro b/app/android/app/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/app/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/app/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/app/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
new file mode 100644
index 0000000..f2c2217
--- /dev/null
+++ b/app/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
@@ -0,0 +1,26 @@
+package com.getcapacitor.myapp;
+
+import static org.junit.Assert.*;
+
+import android.content.Context;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see Testing documentation
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+
+ @Test
+ public void useAppContext() throws Exception {
+ // Context of the app under test.
+ Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+
+ assertEquals("com.getcapacitor.app", appContext.getPackageName());
+ }
+}
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..845d075
--- /dev/null
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java b/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java
new file mode 100644
index 0000000..d08d6bf
--- /dev/null
+++ b/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java
@@ -0,0 +1,5 @@
+package com.goldenchart.app;
+
+import com.getcapacitor.BridgeActivity;
+
+public class MainActivity extends BridgeActivity {}
diff --git a/app/android/app/src/main/res/drawable-land-hdpi/splash.png b/app/android/app/src/main/res/drawable-land-hdpi/splash.png
new file mode 100644
index 0000000..e31573b
Binary files /dev/null and b/app/android/app/src/main/res/drawable-land-hdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-land-mdpi/splash.png b/app/android/app/src/main/res/drawable-land-mdpi/splash.png
new file mode 100644
index 0000000..f7a6492
Binary files /dev/null and b/app/android/app/src/main/res/drawable-land-mdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-land-xhdpi/splash.png b/app/android/app/src/main/res/drawable-land-xhdpi/splash.png
new file mode 100644
index 0000000..8077255
Binary files /dev/null and b/app/android/app/src/main/res/drawable-land-xhdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/app/android/app/src/main/res/drawable-land-xxhdpi/splash.png
new file mode 100644
index 0000000..14c6c8f
Binary files /dev/null and b/app/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
new file mode 100644
index 0000000..244ca25
Binary files /dev/null and b/app/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-port-hdpi/splash.png b/app/android/app/src/main/res/drawable-port-hdpi/splash.png
new file mode 100644
index 0000000..74faaa5
Binary files /dev/null and b/app/android/app/src/main/res/drawable-port-hdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-port-mdpi/splash.png b/app/android/app/src/main/res/drawable-port-mdpi/splash.png
new file mode 100644
index 0000000..e944f4a
Binary files /dev/null and b/app/android/app/src/main/res/drawable-port-mdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-port-xhdpi/splash.png b/app/android/app/src/main/res/drawable-port-xhdpi/splash.png
new file mode 100644
index 0000000..564a82f
Binary files /dev/null and b/app/android/app/src/main/res/drawable-port-xhdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/app/android/app/src/main/res/drawable-port-xxhdpi/splash.png
new file mode 100644
index 0000000..bfabe68
Binary files /dev/null and b/app/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
new file mode 100644
index 0000000..6929071
Binary files /dev/null and b/app/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ
diff --git a/app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000..c7bd21d
--- /dev/null
+++ b/app/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/drawable/ic_launcher_background.xml b/app/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..d5fccc5
--- /dev/null
+++ b/app/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/drawable/splash.png b/app/android/app/src/main/res/drawable/splash.png
new file mode 100644
index 0000000..f7a6492
Binary files /dev/null and b/app/android/app/src/main/res/drawable/splash.png differ
diff --git a/app/android/app/src/main/res/layout/activity_main.xml b/app/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..b5ad138
--- /dev/null
+++ b/app/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..c023e50
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2127973
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b441f37
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..72905b8
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..8ed0605
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..9502e47
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d1e077
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..df0f158
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..853db04
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..6cdf97c
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2960cbb
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8e3093a
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..46de6e2
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..d2ea9ab
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..a40d73e
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/android/app/src/main/res/values/ic_launcher_background.xml b/app/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/app/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/android/app/src/main/res/values/strings.xml b/app/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..f21ce15
--- /dev/null
+++ b/app/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ GoldenChart
+ GoldenChart
+ com.goldenchart.app
+ com.goldenchart.app
+
diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..be874e5
--- /dev/null
+++ b/app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/app/src/main/res/xml/file_paths.xml b/app/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..bd0c4d8
--- /dev/null
+++ b/app/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/app/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
new file mode 100644
index 0000000..0297327
--- /dev/null
+++ b/app/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
@@ -0,0 +1,18 @@
+package com.getcapacitor.myapp;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+
+ @Test
+ public void addition_isCorrect() throws Exception {
+ assertEquals(4, 2 + 2);
+ }
+}
diff --git a/app/android/build.gradle b/app/android/build.gradle
new file mode 100644
index 0000000..f1b3b0e
--- /dev/null
+++ b/app/android/build.gradle
@@ -0,0 +1,29 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.7.2'
+ classpath 'com.google.gms:google-services:4.4.2'
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+apply from: "variables.gradle"
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/app/android/capacitor.settings.gradle b/app/android/capacitor.settings.gradle
new file mode 100644
index 0000000..7d5e659
--- /dev/null
+++ b/app/android/capacitor.settings.gradle
@@ -0,0 +1,21 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+include ':capacitor-android'
+project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
+
+include ':capacitor-app'
+project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
+
+include ':capacitor-haptics'
+project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacitor/haptics/android')
+
+include ':capacitor-preferences'
+project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android')
+
+include ':capacitor-push-notifications'
+project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android')
+
+include ':capacitor-splash-screen'
+project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android')
+
+include ':capacitor-status-bar'
+project(':capacitor-status-bar').projectDir = new File('../../node_modules/@capacitor/status-bar/android')
diff --git a/app/android/goldenchart-release.keystore b/app/android/goldenchart-release.keystore
new file mode 100644
index 0000000..0d561bc
Binary files /dev/null and b/app/android/goldenchart-release.keystore differ
diff --git a/app/android/gradle.properties b/app/android/gradle.properties
new file mode 100644
index 0000000..2e87c52
--- /dev/null
+++ b/app/android/gradle.properties
@@ -0,0 +1,22 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
diff --git a/app/android/gradle/wrapper/gradle-wrapper.jar b/app/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/app/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..c1d5e01
--- /dev/null
+++ b/app/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/app/android/gradlew b/app/android/gradlew
new file mode 100755
index 0000000..df72432
--- /dev/null
+++ b/app/android/gradlew
@@ -0,0 +1,258 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# GoldenChart: use Temurin JDK 21 from ~/.local when JAVA_HOME is unset
+if [ -z "$JAVA_HOME" ] && [ -x "$HOME/.local/jdk-21.0.11+10/Contents/Home/bin/java" ]; then
+ JAVA_HOME="$HOME/.local/jdk-21.0.11+10/Contents/Home"
+ export JAVA_HOME
+fi
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/app/android/gradlew.bat b/app/android/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/app/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/app/android/settings.gradle b/app/android/settings.gradle
new file mode 100644
index 0000000..3b4431d
--- /dev/null
+++ b/app/android/settings.gradle
@@ -0,0 +1,5 @@
+include ':app'
+include ':capacitor-cordova-android-plugins'
+project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
+
+apply from: 'capacitor.settings.gradle'
\ No newline at end of file
diff --git a/app/android/variables.gradle b/app/android/variables.gradle
new file mode 100644
index 0000000..2c8e408
--- /dev/null
+++ b/app/android/variables.gradle
@@ -0,0 +1,16 @@
+ext {
+ minSdkVersion = 23
+ compileSdkVersion = 35
+ targetSdkVersion = 35
+ androidxActivityVersion = '1.9.2'
+ androidxAppCompatVersion = '1.7.0'
+ androidxCoordinatorLayoutVersion = '1.2.0'
+ androidxCoreVersion = '1.15.0'
+ androidxFragmentVersion = '1.8.4'
+ coreSplashScreenVersion = '1.0.1'
+ androidxWebkitVersion = '1.12.1'
+ junitVersion = '4.13.2'
+ androidxJunitVersion = '1.2.1'
+ androidxEspressoCoreVersion = '3.6.1'
+ cordovaAndroidVersion = '10.1.1'
+}
\ No newline at end of file
diff --git a/app/capacitor.config.ts b/app/capacitor.config.ts
new file mode 100644
index 0000000..1471206
--- /dev/null
+++ b/app/capacitor.config.ts
@@ -0,0 +1,23 @@
+import type { CapacitorConfig } from '@capacitor/cli';
+
+const config: CapacitorConfig = {
+ appId: 'com.goldenchart.app',
+ appName: 'GoldenChart',
+ webDir: 'dist',
+ server: {
+ androidScheme: 'https',
+ iosScheme: 'capacitor',
+ },
+ plugins: {
+ SplashScreen: {
+ launchAutoHide: true,
+ backgroundColor: '#0f0f23',
+ showSpinner: false,
+ },
+ PushNotifications: {
+ presentationOptions: ['badge', 'sound', 'alert'],
+ },
+ },
+};
+
+export default config;
diff --git a/app/index.html b/app/index.html
new file mode 100644
index 0000000..4cbed4c
--- /dev/null
+++ b/app/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+ GoldenChart
+
+
+
+
+
+
+
+
+
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..0e80320
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@goldenchart/app",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "cap:sync": "npm run build && npx cap sync",
+ "cap:open:android": "npx cap open android",
+ "cap:open:ios": "npx cap open ios"
+ },
+ "dependencies": {
+ "@capacitor/android": "^7.0.0",
+ "@capacitor/app": "^7.0.0",
+ "@capacitor/core": "^7.0.0",
+ "@capacitor/haptics": "^7.0.0",
+ "@capacitor/ios": "^7.0.0",
+ "@capacitor/preferences": "^7.0.0",
+ "@capacitor/push-notifications": "^7.0.0",
+ "@capacitor/splash-screen": "^7.0.0",
+ "@capacitor/status-bar": "^7.0.0",
+ "@goldenchart/shared": "*",
+ "@stomp/stompjs": "^7.3.0",
+ "@xyflow/react": "^12.10.2",
+ "lightweight-charts": "^5.2.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "sockjs-client": "^1.6.1"
+ },
+ "devDependencies": {
+ "@capacitor/cli": "^7.0.0",
+ "@types/react": "^18.3.1",
+ "@types/react-dom": "^18.3.1",
+ "@types/sockjs-client": "^1.5.4",
+ "@vitejs/plugin-react": "^4.3.1",
+ "typescript": "^5.5.4",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/app/public/icon.svg b/app/public/icon.svg
new file mode 100644
index 0000000..eec74e9
--- /dev/null
+++ b/app/public/icon.svg
@@ -0,0 +1,10 @@
+
diff --git a/app/resources/android/colors.xml b/app/resources/android/colors.xml
new file mode 100644
index 0000000..925151b
--- /dev/null
+++ b/app/resources/android/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #8b5cf6
+ #0f0f23
+ #8b5cf6
+ #0f0f23
+
diff --git a/app/resources/icon.json b/app/resources/icon.json
new file mode 100644
index 0000000..e1517d3
--- /dev/null
+++ b/app/resources/icon.json
@@ -0,0 +1,8 @@
+{
+ "images": [
+ {
+ "src": "../public/icon.svg",
+ "type": "svg"
+ }
+ ]
+}
diff --git a/app/src/App.tsx b/app/src/App.tsx
new file mode 100644
index 0000000..cd3a736
--- /dev/null
+++ b/app/src/App.tsx
@@ -0,0 +1,125 @@
+import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
+import { Capacitor } from '@capacitor/core';
+import { StatusBar, Style } from '@capacitor/status-bar';
+import { SplashScreen } from '@capacitor/splash-screen';
+import { initStorage } from './lib/shared';
+import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
+import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
+import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
+import TabBar, { type TabId } from './components/TabBar';
+import { initFcmPush, type FcmPayload } from './services/fcm';
+import LiveSignalBridge from './components/LiveSignalBridge';
+import './theme/global.css';
+
+const VirtualTradingScreen = lazy(() => import('./screens/virtual/VirtualTradingScreen'));
+const StrategyEditorScreen = lazy(() => import('./screens/strategy/StrategyEditorScreen'));
+const NotificationsScreen = lazy(() => import('./screens/notifications/NotificationsScreen'));
+const SettingsScreen = lazy(() => import('./screens/settings/SettingsScreen'));
+
+function ToastStack() {
+ const { toastNotifications, dismissToast } = useTradeNotification();
+ if (toastNotifications.length === 0) return null;
+ return (
+
+ {toastNotifications.slice(0, 3).map(t => (
+
dismissToast(t.id)}>
+
+ {t.signalType === 'BUY' ? '매수' : '매도'}
+
+ {' '}{t.market} · ₩{t.price?.toLocaleString()}
+
+ ))}
+
+ );
+}
+
+function AppInner() {
+ const { tab, setTab, openVirtualFocus } = useNavigation();
+ const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
+ const { settings, isLoaded } = useAppSettings();
+ const defaults = resolveAppDefaults(settings);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ void (async () => {
+ await initStorage();
+ if (Capacitor.isNativePlatform()) {
+ try {
+ await StatusBar.setStyle({ style: Style.Dark });
+ await StatusBar.setBackgroundColor({ color: '#0f0f23' });
+ await SplashScreen.hide();
+ } catch { /* web */ }
+ }
+ setReady(true);
+ })();
+ }, []);
+
+ useEffect(() => {
+ if (!isLoaded || !defaults.fcmPushEnabled) return;
+ void initFcmPush({
+ onForeground: (title, body, data) => {
+ if (data?.market && data?.signalType) {
+ addNotification({
+ market: data.market,
+ signalType: data.signalType,
+ price: Number(data.price) || 0,
+ candleTime: Math.floor(Date.now() / 1000),
+ dbId: data.signalId ? Number(data.signalId) : undefined,
+ });
+ } else {
+ void refreshHistory();
+ }
+ },
+ onTap: (data: FcmPayload) => {
+ if (data.market) {
+ setTab('virtual');
+ openVirtualFocus(data.market);
+ } else {
+ setTab('notifications');
+ }
+ },
+ });
+ }, [isLoaded, defaults.fcmPushEnabled, addNotification, refreshHistory, setTab, openVirtualFocus]);
+
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
+ }, [defaults.theme]);
+
+ if (!ready) {
+ return GoldenChart
;
+ }
+
+ const screens: Record = {
+ virtual: ,
+ strategy: ,
+ notifications: ,
+ settings: ,
+ };
+
+ return (
+
+
+
+
+ 로딩…
}>
+ {screens[tab]}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/BottomSheet.tsx b/app/src/components/BottomSheet.tsx
new file mode 100644
index 0000000..e274a14
--- /dev/null
+++ b/app/src/components/BottomSheet.tsx
@@ -0,0 +1,106 @@
+import React, { useEffect } from 'react';
+
+interface BottomSheetProps {
+ open: boolean;
+ title?: string;
+ onClose: () => void;
+ children: React.ReactNode;
+ height?: 'auto' | 'half' | 'full';
+}
+
+export default function BottomSheet({
+ open,
+ title,
+ onClose,
+ children,
+ height = 'auto',
+}: BottomSheetProps) {
+ useEffect(() => {
+ if (!open) return;
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => { document.body.style.overflow = prev; };
+ }, [open]);
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+ {title && (
+
+
{title}
+
+
+ )}
+
{children}
+
+
+
+ );
+}
diff --git a/app/src/components/LiveSignalBridge.tsx b/app/src/components/LiveSignalBridge.tsx
new file mode 100644
index 0000000..52d3e10
--- /dev/null
+++ b/app/src/components/LiveSignalBridge.tsx
@@ -0,0 +1,37 @@
+import { useEffect } from 'react';
+import { loadVirtualTargets, loadVirtualSession } from '@frontend/utils/virtualTradingStorage';
+import { useLiveStrategyMarkers } from '@frontend/hooks/useLiveStrategyMarkers';
+import { useTradeNotification } from '../contexts/TradeNotificationContext';
+
+/** 가상매매 대상 종목 STOMP 시그널 → 알림 Context */
+export default function LiveSignalBridge() {
+ const { addNotification, refreshHistory } = useTradeNotification();
+ const targets = loadVirtualTargets();
+ const session = loadVirtualSession();
+ const markets = targets.map(t => t.market);
+
+ useLiveStrategyMarkers({
+ markets,
+ activeMarket: markets[0] ?? 'KRW-BTC',
+ enabled: session.running && markets.length > 0,
+ onMarkersChange: () => {},
+ onSignal: marker => {
+ addNotification({
+ market: marker.market,
+ signalType: marker.signal,
+ price: marker.price,
+ candleTime: marker.time,
+ candleType: marker.candleType ?? '1m',
+ });
+ window.setTimeout(() => { void refreshHistory(); }, 800);
+ },
+ });
+
+ useEffect(() => {
+ if (!session.running) return;
+ const id = window.setInterval(() => { void refreshHistory(); }, 20000);
+ return () => window.clearInterval(id);
+ }, [session.running, refreshHistory]);
+
+ return null;
+}
diff --git a/app/src/components/SegmentedControl.tsx b/app/src/components/SegmentedControl.tsx
new file mode 100644
index 0000000..7a0cb29
--- /dev/null
+++ b/app/src/components/SegmentedControl.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+interface SegmentedControlProps {
+ options: { value: T; label: string }[];
+ value: T;
+ onChange: (v: T) => void;
+ size?: 'sm' | 'md';
+}
+
+export default function SegmentedControl({
+ options,
+ value,
+ onChange,
+ size = 'md',
+}: SegmentedControlProps) {
+ return (
+
+ {options.map(opt => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/src/components/SettingsList.tsx b/app/src/components/SettingsList.tsx
new file mode 100644
index 0000000..6137cf2
--- /dev/null
+++ b/app/src/components/SettingsList.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+
+interface SettingRowProps {
+ label: string;
+ description?: string;
+ children: React.ReactNode;
+}
+
+export function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
+ return (
+
+ {title && {title}
}
+ {children}
+
+
+ );
+}
+
+export function SettingRow({ label, description, children }: SettingRowProps) {
+ return (
+
+
+ {label}
+ {description && {description}}
+
+
{children}
+
+
+ );
+}
+
+export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
+ return (
+
+ );
+}
diff --git a/app/src/components/TabBar.tsx b/app/src/components/TabBar.tsx
new file mode 100644
index 0000000..0cb4cf4
--- /dev/null
+++ b/app/src/components/TabBar.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+
+export type TabId = 'virtual' | 'strategy' | 'notifications' | 'settings';
+
+interface TabBarProps {
+ active: TabId;
+ onChange: (tab: TabId) => void;
+ unreadCount: number;
+}
+
+const TABS: { id: TabId; label: string; icon: string }[] = [
+ { id: 'virtual', label: '가상매매', icon: '📊' },
+ { id: 'strategy', label: '전략', icon: '🔀' },
+ { id: 'notifications', label: '알림', icon: '🔔' },
+ { id: 'settings', label: '설정', icon: '⚙️' },
+];
+
+export default function TabBar({ active, onChange, unreadCount }: TabBarProps) {
+ return (
+
+ );
+}
diff --git a/app/src/contexts/NavigationContext.tsx b/app/src/contexts/NavigationContext.tsx
new file mode 100644
index 0000000..0d73f2f
--- /dev/null
+++ b/app/src/contexts/NavigationContext.tsx
@@ -0,0 +1,37 @@
+import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
+import type { TabId } from '../components/TabBar';
+
+interface NavContextValue {
+ tab: TabId;
+ setTab: (t: TabId) => void;
+ focusMarket: string | null;
+ openVirtualFocus: (market: string) => void;
+ clearVirtualFocus: () => void;
+}
+
+const NavContext = createContext(null);
+
+export function NavigationProvider({ children }: { children: React.ReactNode }) {
+ const [tab, setTab] = useState('virtual');
+ const [focusMarket, setFocusMarket] = useState(null);
+
+ const openVirtualFocus = useCallback((market: string) => {
+ setFocusMarket(market);
+ setTab('virtual');
+ }, []);
+
+ const clearVirtualFocus = useCallback(() => setFocusMarket(null), []);
+
+ const value = useMemo(
+ () => ({ tab, setTab, focusMarket, openVirtualFocus, clearVirtualFocus }),
+ [tab, focusMarket, openVirtualFocus, clearVirtualFocus],
+ );
+
+ return {children};
+}
+
+export function useNavigation() {
+ const ctx = useContext(NavContext);
+ if (!ctx) throw new Error('useNavigation requires NavigationProvider');
+ return ctx;
+}
diff --git a/app/src/contexts/TradeNotificationContext.tsx b/app/src/contexts/TradeNotificationContext.tsx
new file mode 100644
index 0000000..0e6da86
--- /dev/null
+++ b/app/src/contexts/TradeNotificationContext.tsx
@@ -0,0 +1,216 @@
+import {
+ deleteAllTradeSignals,
+ deleteTradeSignal,
+ deleteTradeSignalsBatch,
+ loadTradeSignals,
+ type TradeSignalDto,
+} from '../lib/shared';
+import { getUiPreferences, patchUiPreferences } from '@frontend/utils/uiPreferencesDb';
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+export interface TradeSignalInfo {
+ market: string;
+ signalType: string;
+ price: number;
+ candleTime: number;
+ strategyName?: string;
+ strategyId?: number;
+ executionType?: string;
+ candleType?: string;
+}
+
+export interface TradeNotificationItem extends TradeSignalInfo {
+ id: string;
+ dbId?: number;
+ isRead: boolean;
+ receivedAt: number;
+}
+
+interface TradeNotificationContextValue {
+ toastNotifications: TradeNotificationItem[];
+ allNotifications: TradeNotificationItem[];
+ unreadCount: number;
+ addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void;
+ dismissToast: (id: string) => void;
+ dismissAllToasts: () => void;
+ markAsRead: (id: string) => void;
+ markAllAsRead: () => void;
+ deleteNotification: (id: string) => Promise;
+ deleteAllNotifications: () => Promise;
+ refreshHistory: () => Promise;
+}
+
+const TradeNotificationContext = createContext(null);
+
+function makeId(signal: Pick): string {
+ return `${signal.market}:${signal.candleTime}:${signal.signalType}`;
+}
+
+function loadReadIds(): Set {
+ return new Set(getUiPreferences().tradeNotifications?.readIds ?? []);
+}
+
+function saveReadIds(ids: Set) {
+ patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
+}
+
+function loadHiddenIds(): Set {
+ return new Set(getUiPreferences().tradeNotifications?.hiddenIds ?? []);
+}
+
+function saveHiddenIds(ids: Set) {
+ patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
+}
+
+function dtoToItem(dto: TradeSignalDto, readIds: Set): TradeNotificationItem {
+ const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`;
+ return {
+ id,
+ dbId: dto.id,
+ market: dto.market,
+ signalType: dto.signalType,
+ price: dto.price,
+ candleTime: dto.candleTime,
+ strategyName: dto.strategyName,
+ strategyId: dto.strategyId,
+ executionType: dto.executionType,
+ candleType: dto.candleType,
+ isRead: readIds.has(id),
+ receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000,
+ };
+}
+
+export function useTradeNotification() {
+ const ctx = useContext(TradeNotificationContext);
+ if (!ctx) throw new Error('useTradeNotification requires provider');
+ return ctx;
+}
+
+export function TradeNotificationProvider({
+ children,
+ soundEnabled = true,
+}: {
+ children: React.ReactNode;
+ soundEnabled?: boolean;
+}) {
+ const [toasts, setToasts] = useState([]);
+ const [all, setAll] = useState([]);
+ const readRef = useRef(loadReadIds());
+ const hiddenRef = useRef(loadHiddenIds());
+
+ const refreshHistory = useCallback(async () => {
+ readRef.current = loadReadIds();
+ hiddenRef.current = loadHiddenIds();
+ const dtos = await loadTradeSignals();
+ const items = (dtos ?? [])
+ .map(d => dtoToItem(d, readRef.current))
+ .filter(i => !hiddenRef.current.has(i.id))
+ .sort((a, b) => b.receivedAt - a.receivedAt);
+ setAll(items);
+ }, []);
+
+ useEffect(() => {
+ void refreshHistory();
+ const t = setInterval(() => void refreshHistory(), 20000);
+ return () => clearInterval(t);
+ }, [refreshHistory]);
+
+ const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => {
+ const id = makeId(signal);
+ if (hiddenRef.current.has(id)) return;
+ const item: TradeNotificationItem = {
+ ...signal,
+ id,
+ isRead: readRef.current.has(id),
+ receivedAt: Date.now(),
+ };
+ setAll(prev => {
+ const filtered = prev.filter(p => p.id !== id);
+ return [item, ...filtered].slice(0, 300);
+ });
+ if (!item.isRead) {
+ setToasts(prev => [item, ...prev.filter(p => p.id !== id)].slice(0, 50));
+ if (soundEnabled && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
+ navigator.vibrate(80);
+ }
+ }
+ }, [soundEnabled]);
+
+ const dismissToast = useCallback((id: string) => {
+ setToasts(prev => prev.filter(t => t.id !== id));
+ readRef.current.add(id);
+ saveReadIds(readRef.current);
+ setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
+ }, []);
+
+ const dismissAllToasts = useCallback(() => {
+ setToasts(prev => {
+ prev.forEach(t => readRef.current.add(t.id));
+ saveReadIds(readRef.current);
+ return [];
+ });
+ setAll(prev => prev.map(n => ({ ...n, isRead: true })));
+ }, []);
+
+ const markAsRead = useCallback((id: string) => {
+ readRef.current.add(id);
+ saveReadIds(readRef.current);
+ setAll(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)));
+ setToasts(prev => prev.filter(t => t.id !== id));
+ }, []);
+
+ const markAllAsRead = useCallback(() => {
+ all.forEach(n => readRef.current.add(n.id));
+ saveReadIds(readRef.current);
+ setAll(prev => prev.map(n => ({ ...n, isRead: true })));
+ setToasts([]);
+ }, [all]);
+
+ const deleteNotification = useCallback(async (id: string) => {
+ const item = all.find(n => n.id === id);
+ hiddenRef.current.add(id);
+ saveHiddenIds(hiddenRef.current);
+ if (item?.dbId) await deleteTradeSignal(item.dbId);
+ setAll(prev => prev.filter(n => n.id !== id));
+ setToasts(prev => prev.filter(t => t.id !== id));
+ }, [all]);
+
+ const deleteAllNotifications = useCallback(async () => {
+ await deleteAllTradeSignals();
+ all.forEach(n => hiddenRef.current.add(n.id));
+ saveHiddenIds(hiddenRef.current);
+ setAll([]);
+ setToasts([]);
+ }, [all]);
+
+ const unreadCount = useMemo(() => all.filter(n => !n.isRead).length, [all]);
+
+ const value = useMemo(
+ () => ({
+ toastNotifications: toasts,
+ allNotifications: all,
+ unreadCount,
+ addNotification,
+ dismissToast,
+ dismissAllToasts,
+ markAsRead,
+ markAllAsRead,
+ deleteNotification,
+ deleteAllNotifications,
+ refreshHistory,
+ }),
+ [toasts, all, unreadCount, addNotification, dismissToast, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteAllNotifications, refreshHistory],
+ );
+
+ return (
+ {children}
+ );
+}
diff --git a/app/src/hooks/useAppSettings.ts b/app/src/hooks/useAppSettings.ts
new file mode 100644
index 0000000..f8a4118
--- /dev/null
+++ b/app/src/hooks/useAppSettings.ts
@@ -0,0 +1,7 @@
+export {
+ useAppSettings,
+ getAppSettingsCache,
+ subscribeAppSettings,
+ resolveAppDefaults,
+ invalidateAppSettingsCache,
+} from '@frontend/hooks/useAppSettings';
diff --git a/app/src/lib/shared.ts b/app/src/lib/shared.ts
new file mode 100644
index 0000000..7068d37
--- /dev/null
+++ b/app/src/lib/shared.ts
@@ -0,0 +1,10 @@
+export * from '../../../packages/shared/src/api/backendApi';
+export {
+ initStorage,
+ storageGet,
+ storageSet,
+ storageGetSync,
+ storageSetSync,
+ storageRemove,
+} from '../../../packages/shared/src/storage/index';
+export * from '../../../packages/shared/src/auth';
diff --git a/app/src/main.tsx b/app/src/main.tsx
new file mode 100644
index 0000000..c018515
--- /dev/null
+++ b/app/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/app/src/screens/notifications/NotificationsScreen.tsx b/app/src/screens/notifications/NotificationsScreen.tsx
new file mode 100644
index 0000000..ec9b3d1
--- /dev/null
+++ b/app/src/screens/notifications/NotificationsScreen.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { useTradeNotification } from '../../contexts/TradeNotificationContext';
+import { useNavigation } from '../../contexts/NavigationContext';
+
+export default function NotificationsScreen() {
+ const {
+ allNotifications,
+ unreadCount,
+ markAsRead,
+ markAllAsRead,
+ deleteNotification,
+ deleteAllNotifications,
+ refreshHistory,
+ } = useTradeNotification();
+ const { openVirtualFocus } = useNavigation();
+ const [refreshing, setRefreshing] = React.useState(false);
+
+ const onRefresh = async () => {
+ setRefreshing(true);
+ await refreshHistory();
+ setRefreshing(false);
+ };
+
+ return (
+
+
+
+ {allNotifications.length === 0 ? (
+
+
알림 없음
+
전략 조건 충족 시 알림이 표시됩니다
+
+ ) : (
+
+ {allNotifications.map(item => (
+
{
+ markAsRead(item.id);
+ openVirtualFocus(item.market);
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+
+
+ {item.signalType === 'BUY' ? '매수' : '매도'}
+
+ {item.market}
+
+
+
+
₩{item.price?.toLocaleString()}
+ {item.strategyName && (
+
{item.strategyName}
+ )}
+
+ {new Date(item.receivedAt).toLocaleString('ko-KR')}
+
+
+ ))}
+
+ )}
+
+ {allNotifications.length > 0 && (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/src/screens/settings/SettingsScreen.tsx b/app/src/screens/settings/SettingsScreen.tsx
new file mode 100644
index 0000000..4034cd6
--- /dev/null
+++ b/app/src/screens/settings/SettingsScreen.tsx
@@ -0,0 +1,186 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ loginUser,
+ resetPaperAccount,
+ sendFcmTest,
+ loadFcmStatus,
+ setApiBase,
+ getStoredDeviceId,
+ type AppSettingsDto,
+} from '../../lib/shared';
+import { getAuthSession, setAuthSession, clearAuthSession } from '../../lib/shared';
+import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
+import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
+import { initFcmPush, checkPushPermission } from '../../services/fcm';
+import { API_BASE } from '../../lib/shared';
+
+export default function SettingsScreen() {
+ const { settings, save, isLoaded } = useAppSettings();
+ const defaults = resolveAppDefaults(settings);
+ const session = getAuthSession();
+
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [apiUrl, setApiUrl] = useState(API_BASE);
+ const [fcmAvailable, setFcmAvailable] = useState(false);
+ const [pushPerm, setPushPerm] = useState('prompt');
+ const [loginError, setLoginError] = useState('');
+
+ useEffect(() => {
+ void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
+ void checkPushPermission().then(setPushPerm);
+ }, []);
+
+ const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters[0]), [save]);
+
+ const handleLogin = async () => {
+ setLoginError('');
+ try {
+ const res = await loginUser(username, password);
+ setAuthSession({
+ userId: res.userId,
+ username: res.username,
+ displayName: res.displayName ?? res.username,
+ role: res.role === 'ADMIN' ? 'ADMIN' : 'USER',
+ });
+ setUsername('');
+ setPassword('');
+ } catch (e) {
+ setLoginError(e instanceof Error ? e.message : '로그인 실패');
+ }
+ };
+
+ const handleLogout = async () => {
+ await clearAuthSession();
+ window.location.reload();
+ };
+
+ const handleFcmToggle = async (enabled: boolean) => {
+ patch({ fcmPushEnabled: enabled });
+ if (enabled) {
+ await initFcmPush();
+ setPushPerm(await checkPushPermission());
+ }
+ };
+
+ if (!isLoaded) return 설정 로딩…
;
+
+ return (
+
+
+
+
+
+
+
+
+ patch({ displayTimezone: e.target.value })}
+ style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent', width: 140 }}
+ />
+
+
+
+
+
+ patch({ liveStrategyCheck: v })} label="실시간 전략 체크" />
+
+
+
+
+
+
+
+
+ patch({ paperTradingEnabled: v })} label="모의투자" />
+
+
+ patch({ paperAutoTradeEnabled: v })} label="자동 모의 체결" />
+
+
+ patch({ virtualTargetMaxCount: Number(e.target.value) })}
+ style={{ width: 64, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }}
+ />
+
+
+
+
+
+
+
+
+ patch({ tradeAlertPopup: v })} label="매매 알림" />
+
+
+ patch({ tradeAlertSoundEnabled: v })} label="알림 사운드" />
+
+
+
+
+
+ void handleFcmToggle(v)} label="FCM 푸시" />
+
+
+ {pushPerm}
+
+
+
+
+
+
+
+
+ setApiUrl(e.target.value)}
+ style={{ width: 160, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent', fontSize: 11 }}
+ />
+
+
+
+
+
+ {getStoredDeviceId()}
+
+ {session ? (
+
+
+
+ ) : (
+ <>
+
+ setUsername(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
+
+
+ setPassword(e.target.value)} style={{ width: 120, padding: 8, borderRadius: 8, border: '1px solid var(--gc-border)', background: 'transparent' }} />
+
+
+
+
+ {loginError && {loginError}
}
+ >
+ )}
+
+
+ );
+}
diff --git a/app/src/screens/strategy/StrategyEditorScreen.tsx b/app/src/screens/strategy/StrategyEditorScreen.tsx
new file mode 100644
index 0000000..edfedca
--- /dev/null
+++ b/app/src/screens/strategy/StrategyEditorScreen.tsx
@@ -0,0 +1,187 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ loadStrategies,
+ loadStrategy,
+ saveStrategy,
+ deleteStrategy,
+ type StrategyDto,
+} from '../../lib/shared';
+import type { LogicNode } from '@frontend/utils/strategyTypes';
+import { genId } from '@frontend/utils/strategyEditorShared';
+
+const emptyAnd = (): LogicNode => ({ id: genId(), type: 'AND', children: [] });
+import {
+ decodeConditionForEditor,
+ encodeConditionForSave,
+} from '@frontend/utils/strategyConditionSerde';
+import {
+ downloadStrategyJson,
+ parseStrategyImportFile,
+ pickJsonFile,
+ buildStrategyExportPayload,
+} from '@frontend/utils/strategyImportExport';
+import { loadEditorMode, saveEditorMode, type StrategyEditorMode } from '@frontend/utils/strategyEditorModeStorage';
+import SegmentedControl from '../../components/SegmentedControl';
+import BottomSheet from '../../components/BottomSheet';
+import StrategyFlowMobile from './StrategyFlowMobile';
+
+const emptyEditorState = {
+ root: null as LogicNode | null,
+ startMeta: {},
+ extraStartIds: [] as string[],
+ extraRoots: {} as Record,
+ startCombineOp: 'AND' as const,
+};
+
+export default function StrategyEditorScreen() {
+ const [strategies, setStrategies] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [editorMode, setEditorMode] = useState(() => loadEditorMode());
+ const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
+ const [name, setName] = useState('새 전략');
+ const [description, setDescription] = useState('');
+ const [buyRoot, setBuyRoot] = useState(emptyAnd());
+ const [sellRoot, setSellRoot] = useState(emptyAnd());
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ const refreshList = useCallback(async () => {
+ setStrategies((await loadStrategies()) ?? []);
+ }, []);
+
+ useEffect(() => {
+ void refreshList().finally(() => setLoading(false));
+ }, [refreshList]);
+
+ const loadOne = async (id: number) => {
+ const s = await loadStrategy(id);
+ if (!s) return;
+ setSelectedId(id);
+ setName(s.name);
+ setDescription(s.description ?? '');
+ setBuyRoot(decodeConditionForEditor(s.buyCondition as LogicNode).root);
+ setSellRoot(decodeConditionForEditor(s.sellCondition as LogicNode).root);
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const saved = await saveStrategy({
+ id: selectedId ?? undefined,
+ name,
+ description,
+ buyCondition: encodeConditionForSave({ ...emptyEditorState, root: buyRoot }),
+ sellCondition: encodeConditionForSave({ ...emptyEditorState, root: sellRoot }),
+ enabled: true,
+ });
+ if (saved?.id) {
+ setSelectedId(saved.id);
+ await refreshList();
+ }
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (selectedId == null || !confirm('삭제할까요?')) return;
+ await deleteStrategy(selectedId);
+ setSelectedId(null);
+ setName('새 전략');
+ setBuyRoot(emptyAnd());
+ setSellRoot(emptyAnd());
+ await refreshList();
+ };
+
+ const currentRoot = signalTab === 'buy' ? buyRoot : sellRoot;
+ const setCurrentRoot = signalTab === 'buy' ? setBuyRoot : setSellRoot;
+
+ const handleExport = () => {
+ const payload = buildStrategyExportPayload({
+ name,
+ description,
+ buyCondition: encodeConditionForSave({ ...emptyEditorState, root: buyRoot }),
+ sellCondition: encodeConditionForSave({ ...emptyEditorState, root: sellRoot }),
+ });
+ downloadStrategyJson(name, payload);
+ };
+
+ const handleImport = async () => {
+ const file = await pickJsonFile();
+ if (!file) return;
+ const parsed = await parseStrategyImportFile(file);
+ if (!parsed) return;
+ const data = parsed.kind === 'single' ? parsed.data : parsed.data.strategies[0];
+ if (!data) return;
+ setName(data.name ?? '가져온 전략');
+ setDescription(data.description ?? '');
+ setBuyRoot(decodeConditionForEditor(data.buyCondition as LogicNode).root);
+ setSellRoot(decodeConditionForEditor(data.sellCondition as LogicNode).root);
+ setSelectedId(null);
+ };
+
+ if (loading) return 로딩 중…
;
+
+ return (
+
+
+ 전략편집기
+
+
+
+
+
+
+
+ {selectedId != null && }
+
+
+
+
+ setName(e.target.value)} placeholder="전략 이름" style={{ width: '100%', padding: 12, borderRadius: 10, border: '1px solid var(--gc-border)', background: 'rgba(255,255,255,0.05)', marginBottom: 8 }} />
+
+
+
+ setSignalTab(v)} />
+ { setEditorMode(m); saveEditorMode(m); }} />
+
+
+ {editorMode === 'graph' ? (
+
+ ) : (
+
+
+
+ 상세 조건 편집은 데스크톱 전략편집기와 동기화됩니다. Import/Export JSON으로 조건을 가져올 수 있습니다.
+
+
+
+ )}
+
+ );
+}
+
+function ConditionSummary({ root }: { root: LogicNode | null }) {
+ const count = countConditions(root);
+ return (
+
+
논리 트리
+
루트: {root?.type ?? '없음'} · 조건 {count}개
+
+ );
+}
+
+function countConditions(node: LogicNode | null): number {
+ if (!node) return 0;
+ if (node.type === 'CONDITION') return 1;
+ return (node.children ?? []).reduce((s, c) => s + countConditions(c), 0);
+}
diff --git a/app/src/screens/strategy/StrategyFlowMobile.tsx b/app/src/screens/strategy/StrategyFlowMobile.tsx
new file mode 100644
index 0000000..4c1aee4
--- /dev/null
+++ b/app/src/screens/strategy/StrategyFlowMobile.tsx
@@ -0,0 +1,32 @@
+import React, { useMemo } from 'react';
+import type { LogicNode } from '@frontend/utils/strategyTypes';
+
+interface Props {
+ root: LogicNode | null;
+ signalTab: 'buy' | 'sell';
+}
+
+function countNodes(node: LogicNode | null): number {
+ if (!node) return 0;
+ return 1 + (node.children ?? []).reduce((s, c) => s + countNodes(c), 0);
+}
+
+export default function StrategyFlowMobile({ root, signalTab }: Props) {
+ const count = useMemo(() => countNodes(root), [root]);
+
+ return (
+
+
+
🔀
+
{signalTab === 'buy' ? '매수' : '매도'} 그래프
+
+ {count}개 노드 · 모바일에서는 목록 모드 편집을 권장합니다
+
+
+ 데스크톱 전략편집기에서 저장한 flowLayout 그래프는 그대로 유지됩니다.
+ 조건 추가·편집은 목록 탭을 사용하세요.
+
+
+
+ );
+}
diff --git a/app/src/screens/virtual/VirtualFocusScreen.tsx b/app/src/screens/virtual/VirtualFocusScreen.tsx
new file mode 100644
index 0000000..bc06356
--- /dev/null
+++ b/app/src/screens/virtual/VirtualFocusScreen.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import type { StrategyDto } from '../../lib/shared';
+import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
+import type { VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
+import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
+
+interface Props {
+ target: VirtualTargetItem;
+ session: VirtualSessionConfig;
+ strategies: StrategyDto[];
+ snapshot?: VirtualIndicatorSnapshot;
+ liveConnected: boolean;
+ onBack: () => void;
+ onOpenTrade: () => void;
+}
+
+export default function VirtualFocusScreen({
+ target,
+ snapshot,
+ liveConnected,
+ onBack,
+ onOpenTrade,
+}: Props) {
+ const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
+ const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
+ const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
+
+ return (
+
+
+
+ {target.market}
+
+
+
+
+
+
일치율
+
+ {(snapshot?.matchRate ?? 0).toFixed(0)}%
+
+
+ {liveConnected ? '실시간 연결됨' : '연결 대기'}
+
+
+
+
+
+
{buyPct.toFixed(0)}%
+
매수 조건
+
+
+
{sellPct.toFixed(0)}%
+
매도 조건
+
+
+
+
조건 목록
+
+ {(snapshot?.rows ?? []).map(row => (
+
+
+
{row.displayName}
+
{row.side} · {row.timeframe}
+
+
+ {row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '—'}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/src/screens/virtual/VirtualTargetCardMobile.tsx b/app/src/screens/virtual/VirtualTargetCardMobile.tsx
new file mode 100644
index 0000000..92f8b41
--- /dev/null
+++ b/app/src/screens/virtual/VirtualTargetCardMobile.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import type { StrategyDto } from '../../lib/shared';
+import type { VirtualIndicatorSnapshot } from '@frontend/hooks/useVirtualIndicatorSnapshots';
+import type { VirtualLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
+import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '@frontend/utils/virtualTradingStorage';
+import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
+import { buildConditionMetrics, computeMatchRate } from '@frontend/utils/virtualSignalMetrics';
+
+interface Props {
+ target: VirtualTargetItem;
+ session: VirtualSessionConfig;
+ strategies: StrategyDto[];
+ snapshot?: VirtualIndicatorSnapshot;
+ viewMode: VirtualCardViewMode;
+ liveFlash?: boolean;
+ liveStatus?: VirtualLiveStatus;
+ onFocus: () => void;
+ onTrade: () => void;
+ onRemove: () => void;
+ onTogglePin: () => void;
+ onStrategyChange: (id: number | null) => void;
+ onCandleTypeChange: (ct: string) => void;
+}
+
+export default function VirtualTargetCardMobile({
+ target,
+ session,
+ strategies,
+ snapshot,
+ viewMode,
+ liveFlash,
+ liveStatus,
+ onFocus,
+ onTrade,
+ onRemove,
+ onTogglePin,
+ onStrategyChange,
+}: Props) {
+ const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
+ const strategyName = strategies.find(s => s.id === strategyId)?.name ?? '전략 없음';
+ const metrics = snapshot?.rows?.length ? buildConditionMetrics(snapshot.rows) : [];
+ const buyPct = computeMatchRate(metrics.filter(m => m.row.side === 'buy'), snapshot?.matchRate);
+ const sellPct = computeMatchRate(metrics.filter(m => m.row.side === 'sell'));
+
+ return (
+
+
+
+
{target.koreanName ?? target.market}
+
{target.market}
+
+
+
+
{(snapshot?.matchRate ?? buyPct).toFixed(0)}%
+
+
+
+
+
+
+ {strategyName} · {snapshot?.timeframe ?? '—'}
+
+
+ {viewMode === 'detail' && snapshot?.rows && (
+
+ {snapshot.rows.slice(0, 6).map(row => (
+
+ {row.displayName}
+
+ {row.satisfied === true ? '✓' : row.satisfied === false ? '✗' : '—'}
+
+
+ ))}
+
+ )}
+
+ e.stopPropagation()}>
+
+
+
+ {!target.pinned && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/src/screens/virtual/VirtualTradePanel.tsx b/app/src/screens/virtual/VirtualTradePanel.tsx
new file mode 100644
index 0000000..17ed454
--- /dev/null
+++ b/app/src/screens/virtual/VirtualTradePanel.tsx
@@ -0,0 +1,72 @@
+import React, { useState } from 'react';
+import type { PaperSummaryDto } from '../../lib/shared';
+import { Haptics, ImpactStyle } from '@capacitor/haptics';
+
+interface Props {
+ market: string;
+ summary: PaperSummaryDto | null;
+ onOrder: (side: 'BUY' | 'SELL', qty: number, price: number) => Promise;
+}
+
+export default function VirtualTradePanel({ market, summary, onOrder }: Props) {
+ const [qty, setQty] = useState('0.001');
+ const [price, setPrice] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const position = summary?.positions?.find(p => p.symbol === market);
+
+ const submit = async (side: 'BUY' | 'SELL') => {
+ const q = parseFloat(qty);
+ const p = parseFloat(price) || 0;
+ if (!Number.isFinite(q) || q <= 0) return;
+ setBusy(true);
+ try {
+ await onOrder(side, q, p);
+ try { await Haptics.impact({ style: ImpactStyle.Medium }); } catch { /* web */ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {position && (
+
+
보유
+
{position.quantity} @ ₩{position.avgPrice?.toLocaleString()}
+
+ )}
+
+
setQty(e.target.value)}
+ style={inputStyle}
+ />
+
+
setPrice(e.target.value)}
+ placeholder="시장가"
+ style={inputStyle}
+ />
+
+
+
+
+
+ );
+}
+
+const inputStyle: React.CSSProperties = {
+ width: '100%',
+ padding: '12px 14px',
+ borderRadius: 10,
+ border: '1px solid var(--gc-border)',
+ background: 'rgba(255,255,255,0.05)',
+};
diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx
new file mode 100644
index 0000000..438ebb0
--- /dev/null
+++ b/app/src/screens/virtual/VirtualTradingScreen.tsx
@@ -0,0 +1,340 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ loadPaperSummary,
+ loadPaperTrades,
+ loadStrategies,
+ placePaperOrder,
+ resetPaperAccount,
+ type PaperSummaryDto,
+ type PaperTradeDto,
+ type StrategyDto,
+} from '../../lib/shared';
+import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots';
+import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade';
+import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
+import {
+ loadVirtualSession,
+ loadVirtualTargets,
+ saveVirtualSession,
+ saveVirtualTargets,
+ loadVirtualCardViewMode,
+ saveVirtualCardViewMode,
+ type VirtualSessionConfig,
+ type VirtualTargetItem,
+ type VirtualCardViewMode,
+ resolveTargetCandleType,
+} from '@frontend/utils/virtualTradingStorage';
+import {
+ syncVirtualTargetsToBackend,
+ stopVirtualLiveOnBackend,
+} from '@frontend/utils/virtualLiveStrategySync';
+import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
+import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
+import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
+import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
+import { useNavigation } from '../../contexts/NavigationContext';
+import BottomSheet from '../../components/BottomSheet';
+import SegmentedControl from '../../components/SegmentedControl';
+import VirtualTargetCardMobile from './VirtualTargetCardMobile';
+import VirtualFocusScreen from './VirtualFocusScreen';
+import VirtualTradePanel from './VirtualTradePanel';
+
+type RightTab = 'trade' | 'history';
+
+export default function VirtualTradingScreen() {
+ const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
+ const { settings } = useAppSettings();
+ const defaults = resolveAppDefaults(settings);
+
+ const [targets, setTargets] = useState(() => loadVirtualTargets());
+ const [session, setSession] = useState(() => loadVirtualSession());
+ const [strategies, setStrategies] = useState([]);
+ const [summary, setSummary] = useState(null);
+ const [trades, setTrades] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
+ const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode());
+ const [sheetOpen, setSheetOpen] = useState(false);
+ const [addSheetOpen, setAddSheetOpen] = useState(false);
+ const [rightTab, setRightTab] = useState('trade');
+ const [newMarket, setNewMarket] = useState('');
+
+ const refreshSummary = useCallback(async () => {
+ const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
+ setSummary(s);
+ setTrades(t ?? []);
+ }, []);
+
+ useEffect(() => {
+ void Promise.all([
+ loadStrategies().then(setStrategies).catch(() => []),
+ refreshSummary(),
+ ]).finally(() => setLoading(false));
+ }, [refreshSummary]);
+
+ useEffect(() => { saveVirtualTargets(targets); }, [targets]);
+ useEffect(() => { saveVirtualSession(session); }, [session]);
+ useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
+
+ const targetRefs = useMemo(
+ () => targets.map(t => ({
+ market: t.market,
+ strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
+ })),
+ [targets, session.globalStrategyId],
+ );
+
+ const snapshots = useVirtualIndicatorSnapshots(
+ targetRefs,
+ strategies as Parameters[1],
+ session.running,
+ );
+
+ const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running);
+
+ const liveConnected = useMemo(
+ () => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'),
+ [liveStatus.statusByMarket],
+ );
+
+ useVirtualAutoTrade({
+ targets,
+ session,
+ snapshots,
+ enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
+ paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
+ positions: summary?.positions,
+ onFilled: refreshSummary,
+ });
+
+ const toggleRunning = useCallback(async () => {
+ const next = { ...session, running: !session.running };
+ setSession(next);
+ if (next.running) {
+ await syncVirtualTargetsToBackend(targets, next, true);
+ } else {
+ await stopVirtualLiveOnBackend(targets, next);
+ }
+ }, [session, targets]);
+
+ const handleAddTarget = useCallback(() => {
+ const market = newMarket.trim().toUpperCase();
+ if (!market) return;
+ if (targets.some(t => t.market === market)) return;
+ if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) {
+ alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount));
+ return;
+ }
+ setTargets(prev => [...prev, { market, strategyId: null }]);
+ setNewMarket('');
+ setAddSheetOpen(false);
+ }, [newMarket, targets, defaults.virtualTargetMaxCount]);
+
+ const handleRemoveTarget = useCallback((market: string) => {
+ const t = targets.find(x => x.market === market);
+ if (t?.pinned) return;
+ setTargets(prev => prev.filter(x => x.market !== market));
+ }, [targets]);
+
+ const handleTogglePin = useCallback(async (market: string) => {
+ const t = targets.find(x => x.market === market);
+ if (!t) return;
+ const pinned = !t.pinned;
+ setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x)));
+ await persistVirtualTargetPinned(market, pinned);
+ }, [targets]);
+
+ const pnlPct = summary?.totalReturnPct ?? 0;
+
+ if (focusMarket) {
+ const target = targets.find(t => t.market === focusMarket);
+ if (target) {
+ return (
+ {
+ const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
+ return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
+ })()}
+ liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
+ onBack={clearVirtualFocus}
+ onOpenTrade={() => {
+ setSelectedMarket(focusMarket);
+ setSheetOpen(true);
+ }}
+ />
+ );
+ }
+ clearVirtualFocus();
+ }
+
+ return (
+
+
+ 가상매매
+
+
+
+
+
+
+
총 자산
+
+ ₩{(summary?.totalAsset ?? 0).toLocaleString()}
+
+
+
+
수익률
+
= 0 ? 'text-green' : 'text-red'}`} style={{ fontSize: 22, fontWeight: 700 }}>
+ {pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
+
+
+
+ {!liveConnected && session.running && (
+
실시간 연결 끊김
+ )}
+
+
+
+
+ setSession(s => ({ ...s, executionType: v }))}
+ />
+
+
+
+ setSession(s => ({ ...s, positionMode: v }))}
+ />
+ setViewMode(v)}
+ />
+
+
+ {loading ? (
+
로딩 중…
+ ) : targets.length === 0 ? (
+
+
투자 대상 없음
+
+ 버튼으로 종목을 추가하세요
+
+ ) : (
+
+ {targets.map(target => {
+ const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
+ const snapKey = `${target.market}:${strategyId ?? ''}`;
+ const snap = snapshots[snapKey] ?? snapshots[target.market];
+ return (
+ openVirtualFocus(target.market)}
+ onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
+ onRemove={() => handleRemoveTarget(target.market)}
+ onTogglePin={() => void handleTogglePin(target.market)}
+ onStrategyChange={sid => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, strategyId: sid } : t))}
+ onCandleTypeChange={ct => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, candleType: ct } : t))}
+ />
+ );
+ })}
+
+ )}
+
+
+
+
setAddSheetOpen(false)}>
+ setNewMarket(e.target.value)}
+ style={{
+ width: '100%',
+ padding: '12px 14px',
+ borderRadius: 10,
+ border: '1px solid var(--gc-border)',
+ background: 'rgba(255,255,255,0.05)',
+ marginBottom: 12,
+ }}
+ />
+
+
+
+
setSheetOpen(false)} height="half">
+
+
+
+
+ {rightTab === 'trade' ? (
+ {
+ await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
+ await refreshSummary();
+ }}
+ />
+ ) : (
+
+ {trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
+
+
+ {t.side}
+ {t.quantity} @ ₩{t.price?.toLocaleString()}
+
+
{t.createdAt ?? ''}
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/src/services/fcm.ts b/app/src/services/fcm.ts
new file mode 100644
index 0000000..24a9ba2
--- /dev/null
+++ b/app/src/services/fcm.ts
@@ -0,0 +1,75 @@
+import {
+ registerFcmToken,
+ deleteFcmToken,
+} from '../lib/shared';
+import { PushNotifications } from '@capacitor/push-notifications';
+import { Capacitor } from '@capacitor/core';
+
+export interface FcmPayload {
+ type?: string;
+ market?: string;
+ signalId?: string;
+ signalType?: string;
+ price?: string;
+}
+
+type FcmHandlers = {
+ onForeground?: (title: string, body: string, data?: FcmPayload) => void;
+ onTap?: (data: FcmPayload) => void;
+};
+
+let initialized = false;
+
+export async function initFcmPush(handlers: FcmHandlers = {}): Promise {
+ if (!Capacitor.isNativePlatform()) {
+ console.info('[FCM] Web dev — native push skipped');
+ return false;
+ }
+ if (initialized) return true;
+
+ const perm = await PushNotifications.requestPermissions();
+ if (perm.receive !== 'granted') return false;
+
+ await PushNotifications.addListener('registration', async token => {
+ try {
+ await registerFcmToken(token.value);
+ console.info('[FCM] token registered');
+ } catch (e) {
+ console.warn('[FCM] token register failed', e);
+ }
+ });
+
+ await PushNotifications.addListener('registrationError', err => {
+ console.warn('[FCM] registration error', err);
+ });
+
+ await PushNotifications.addListener('pushNotificationReceived', notification => {
+ const title = notification.title ?? 'GoldenChart';
+ const body = notification.body ?? '';
+ handlers.onForeground?.(title, body, notification.data as FcmPayload);
+ });
+
+ await PushNotifications.addListener('pushNotificationActionPerformed', action => {
+ handlers.onTap?.(action.notification.data as FcmPayload);
+ });
+
+ await PushNotifications.register();
+ initialized = true;
+ return true;
+}
+
+export async function unregisterFcmPush(): Promise {
+ if (!Capacitor.isNativePlatform()) return;
+ try {
+ await deleteFcmToken();
+ } catch { /* ignore */ }
+ initialized = false;
+}
+
+export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt'> {
+ if (!Capacitor.isNativePlatform()) return 'prompt';
+ const perm = await PushNotifications.checkPermissions();
+ if (perm.receive === 'granted') return 'granted';
+ if (perm.receive === 'denied') return 'denied';
+ return 'prompt';
+}
diff --git a/app/src/theme/global.css b/app/src/theme/global.css
new file mode 100644
index 0000000..bbc30ac
--- /dev/null
+++ b/app/src/theme/global.css
@@ -0,0 +1,224 @@
+@import './tokens.css';
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body, #root {
+ height: 100%;
+ overflow: hidden;
+}
+
+body {
+ font-family: var(--gc-font);
+ background: var(--gc-bg);
+ color: var(--gc-text);
+ -webkit-font-smoothing: antialiased;
+ -webkit-tap-highlight-color: transparent;
+ overscroll-behavior: none;
+}
+
+button, input, select, textarea {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+ border: none;
+ background: none;
+ min-height: var(--gc-touch);
+}
+
+a {
+ color: var(--gc-accent);
+}
+
+.app-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding-top: var(--gc-safe-top);
+}
+
+.app-content {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.screen {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ padding-bottom: calc(var(--gc-tab-height) + var(--gc-safe-bottom) + 8px);
+}
+
+.screen-header {
+ padding: 12px 16px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background: var(--gc-bg);
+ backdrop-filter: blur(12px);
+}
+
+.screen-title {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.card {
+ background: var(--gc-bg-card);
+ border: 1px solid var(--gc-border);
+ border-radius: var(--gc-radius-lg);
+ padding: 16px;
+}
+
+.chip-row {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ padding: 0 16px 12px;
+ scrollbar-width: none;
+}
+
+.chip-row::-webkit-scrollbar { display: none; }
+
+.chip {
+ flex-shrink: 0;
+ padding: 8px 14px;
+ border-radius: var(--gc-radius-sm);
+ border: 1px solid var(--gc-border);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--gc-text-muted);
+ font-size: 13px;
+ font-weight: 500;
+ min-height: 36px;
+}
+
+.chip.active {
+ background: var(--gc-accent-soft);
+ border-color: var(--gc-accent);
+ color: var(--gc-accent);
+}
+
+.btn-primary {
+ background: var(--gc-accent);
+ color: #fff;
+ border-radius: var(--gc-radius-sm);
+ padding: 10px 16px;
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.btn-secondary {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--gc-border);
+ border-radius: var(--gc-radius-sm);
+ padding: 10px 16px;
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.btn-danger {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--gc-red);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: var(--gc-radius-sm);
+ padding: 10px 16px;
+}
+
+.text-green { color: var(--gc-green); }
+.text-red { color: var(--gc-red); }
+.text-muted { color: var(--gc-text-muted); }
+
+.loading-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px;
+ color: var(--gc-text-muted);
+}
+
+.empty-state {
+ text-align: center;
+ padding: 48px 24px;
+ color: var(--gc-text-muted);
+}
+
+.empty-state h3 {
+ font-size: 16px;
+ margin-bottom: 8px;
+ color: var(--gc-text);
+}
+
+.fab {
+ position: fixed;
+ right: 16px;
+ bottom: calc(var(--gc-tab-height) + var(--gc-safe-bottom) + 16px);
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--gc-accent);
+ color: #fff;
+ font-size: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 8px 24px rgba(139, 92, 246, 0.4);
+ z-index: 50;
+}
+
+.connection-banner {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--gc-red);
+ text-align: center;
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.toast-stack {
+ position: fixed;
+ top: calc(var(--gc-safe-top) + 8px);
+ left: 12px;
+ right: 12px;
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ pointer-events: none;
+}
+
+.toast-item {
+ pointer-events: auto;
+ background: var(--gc-bg-elevated);
+ border: 1px solid var(--gc-border);
+ border-radius: var(--gc-radius-md);
+ padding: 12px 14px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
+ animation: slideDown 0.25s ease;
+}
+
+@keyframes slideDown {
+ from { opacity: 0; transform: translateY(-12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.pull-indicator {
+ text-align: center;
+ padding: 8px;
+ font-size: 12px;
+ color: var(--gc-text-muted);
+}
diff --git a/app/src/theme/tokens.css b/app/src/theme/tokens.css
new file mode 100644
index 0000000..0a67cd9
--- /dev/null
+++ b/app/src/theme/tokens.css
@@ -0,0 +1,32 @@
+:root {
+ --gc-bg: #0f0f23;
+ --gc-bg-elevated: #1a1a35;
+ --gc-bg-card: linear-gradient(135deg, #1e1e3f 0%, #141428 100%);
+ --gc-border: rgba(255, 255, 255, 0.1);
+ --gc-text: #ffffff;
+ --gc-text-muted: #9ca3af;
+ --gc-text-dim: #71717a;
+ --gc-accent: #8b5cf6;
+ --gc-accent-soft: rgba(139, 92, 246, 0.2);
+ --gc-green: #22c55e;
+ --gc-red: #ef4444;
+ --gc-orange: #f7931a;
+ --gc-radius-sm: 10px;
+ --gc-radius-md: 16px;
+ --gc-radius-lg: 20px;
+ --gc-touch: 44px;
+ --gc-tab-height: 56px;
+ --gc-safe-top: env(safe-area-inset-top, 0px);
+ --gc-safe-bottom: env(safe-area-inset-bottom, 0px);
+ --gc-font: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
+}
+
+[data-theme='light'] {
+ --gc-bg: #f4f4f8;
+ --gc-bg-elevated: #ffffff;
+ --gc-bg-card: linear-gradient(135deg, #ffffff 0%, #f8f8fc 100%);
+ --gc-border: rgba(0, 0, 0, 0.08);
+ --gc-text: #111827;
+ --gc-text-muted: #6b7280;
+ --gc-text-dim: #9ca3af;
+}
diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts
new file mode 100644
index 0000000..f025493
--- /dev/null
+++ b/app/src/vite-env.d.ts
@@ -0,0 +1,15 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
+
+declare module '@goldenchart/shared' {
+ interface ImportMeta {
+ env?: Record;
+ }
+}
diff --git a/app/tsconfig.json b/app/tsconfig.json
new file mode 100644
index 0000000..b692ac0
--- /dev/null
+++ b/app/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": false,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "paths": {
+ "@goldenchart/shared": ["../packages/shared/src/index.ts"],
+ "@goldenchart/shared/*": ["../packages/shared/src/*"],
+ "@frontend/*": ["../frontend/src/*"]
+ }
+ },
+ "include": ["src", "../packages/shared/src"]
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 0000000..4f596bb
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,48 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const frontendRoot = path.resolve(__dirname, '../frontend/src');
+const sharedRoot = path.resolve(__dirname, '../packages/shared/src');
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ dedupe: ['react', 'react-dom', 'lightweight-charts'],
+ alias: {
+ '@goldenchart/shared': sharedRoot,
+ '@frontend': frontendRoot,
+ [path.resolve(frontendRoot, 'utils/backendApi.ts')]: path.resolve(sharedRoot, 'api/backendApi.ts'),
+ },
+ },
+ optimizeDeps: {
+ include: ['react', 'react-dom', '@stomp/stompjs', 'sockjs-client'],
+ },
+ server: {
+ port: 5174,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ ws: true,
+ },
+ '/upbit-api': {
+ target: 'https://api.upbit.com',
+ changeOrigin: true,
+ rewrite: p => p.replace(/^\/upbit-api/, ''),
+ },
+ '/upbit-ws': {
+ target: 'wss://api.upbit.com',
+ changeOrigin: true,
+ ws: true,
+ rewrite: () => '/websocket/v1',
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ },
+});
diff --git a/backend/src/main/java/com/goldenchart/controller/MobileAppReleaseController.java b/backend/src/main/java/com/goldenchart/controller/MobileAppReleaseController.java
new file mode 100644
index 0000000..461574c
--- /dev/null
+++ b/backend/src/main/java/com/goldenchart/controller/MobileAppReleaseController.java
@@ -0,0 +1,43 @@
+package com.goldenchart.controller;
+
+import com.goldenchart.dto.MobileAppReleaseInfoDto;
+import com.goldenchart.service.MobileAppReleaseService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.core.io.Resource;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/mobile-app")
+@RequiredArgsConstructor
+public class MobileAppReleaseController {
+
+ private final MobileAppReleaseService releaseService;
+
+ @GetMapping("/info")
+ public MobileAppReleaseInfoDto info(HttpServletRequest request) {
+ return releaseService.getInfo(resolvePublicBaseUrl(request));
+ }
+
+ @GetMapping("/download/android")
+ public ResponseEntity downloadAndroid() {
+ return releaseService.downloadAndroid();
+ }
+
+ private static String resolvePublicBaseUrl(HttpServletRequest request) {
+ String forwardedProto = request.getHeader("X-Forwarded-Proto");
+ String forwardedHost = request.getHeader("X-Forwarded-Host");
+ if (forwardedHost != null && !forwardedHost.isBlank()) {
+ String scheme = forwardedProto != null && !forwardedProto.isBlank() ? forwardedProto : "https";
+ return scheme + "://" + forwardedHost.split(",")[0].trim();
+ }
+ String scheme = request.getScheme();
+ int port = request.getServerPort();
+ String host = request.getServerName();
+ boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
+ return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
+ }
+}
diff --git a/backend/src/main/java/com/goldenchart/dto/MobileAppReleaseInfoDto.java b/backend/src/main/java/com/goldenchart/dto/MobileAppReleaseInfoDto.java
new file mode 100644
index 0000000..920c50e
--- /dev/null
+++ b/backend/src/main/java/com/goldenchart/dto/MobileAppReleaseInfoDto.java
@@ -0,0 +1,18 @@
+package com.goldenchart.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class MobileAppReleaseInfoDto {
+ private boolean available;
+ private String fileName;
+ private String version;
+ private Long sizeBytes;
+ private String updatedAt;
+ /** 브라우저·QR 스캔용 절대 다운로드 URL */
+ private String downloadUrl;
+ /** 모바일 설치 안내 페이지 URL */
+ private String installPageUrl;
+}
diff --git a/backend/src/main/java/com/goldenchart/dto/StrategyDto.java b/backend/src/main/java/com/goldenchart/dto/StrategyDto.java
index ecefc35..59e56b3 100644
--- a/backend/src/main/java/com/goldenchart/dto/StrategyDto.java
+++ b/backend/src/main/java/com/goldenchart/dto/StrategyDto.java
@@ -23,6 +23,9 @@ public class StrategyDto {
/** 매도 조건 LogicNode 트리 (JSON 그대로 직렬화) */
private JsonNode sellCondition;
+ /** 편집기 flow layout { buy, sell } — START 분봉·노드 좌표 등 */
+ private JsonNode flowLayout;
+
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
diff --git a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java
index c310b75..36176aa 100644
--- a/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java
+++ b/backend/src/main/java/com/goldenchart/entity/GcAppSettings.java
@@ -233,6 +233,11 @@ public class GcAppSettings {
@JdbcTypeCode(SqlTypes.JSON)
private String trendSearchSettingsJson;
+ /** 편집기·팔레트·패널 크기·가상투자 목록 등 UI 설정 통합 JSON */
+ @Column(name = "ui_preferences_json", columnDefinition = "JSON")
+ @JdbcTypeCode(SqlTypes.JSON)
+ private String uiPreferencesJson;
+
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
diff --git a/backend/src/main/java/com/goldenchart/entity/GcStrategy.java b/backend/src/main/java/com/goldenchart/entity/GcStrategy.java
index a672f22..4ec6f47 100644
--- a/backend/src/main/java/com/goldenchart/entity/GcStrategy.java
+++ b/backend/src/main/java/com/goldenchart/entity/GcStrategy.java
@@ -44,6 +44,11 @@ public class GcStrategy {
@JdbcTypeCode(SqlTypes.JSON)
private String sellConditionJson;
+ /** 편집기 flow layout — START 분봉·좌표·orphans (Logic Expression 표시·평가 분봉 동기화) */
+ @Column(name = "flow_layout_json", columnDefinition = "JSON")
+ @JdbcTypeCode(SqlTypes.JSON)
+ private String flowLayoutJson;
+
@Column(name = "enabled", nullable = false)
@Builder.Default
private Boolean enabled = true;
diff --git a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java
index e1f490b..f6ca527 100644
--- a/backend/src/main/java/com/goldenchart/service/AppSettingsService.java
+++ b/backend/src/main/java/com/goldenchart/service/AppSettingsService.java
@@ -174,6 +174,9 @@ public class AppSettingsService {
if (d.containsKey("trendSearchSettings")) {
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
}
+ if (d.containsKey("uiPreferences")) {
+ s.setUiPreferencesJson(toJson(d.get("uiPreferences")));
+ }
}
private Map toMap(GcAppSettings s) {
@@ -226,6 +229,7 @@ public class AppSettingsService {
? s.getLiveAutoTradeBudgetPct().doubleValue() : 95);
m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false);
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
+ m.put("uiPreferences", parseJson(s.getUiPreferencesJson()));
return m;
}
diff --git a/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java b/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java
new file mode 100644
index 0000000..a0926f3
--- /dev/null
+++ b/backend/src/main/java/com/goldenchart/service/MobileAppReleaseService.java
@@ -0,0 +1,134 @@
+package com.goldenchart.service;
+
+import com.goldenchart.dto.MobileAppReleaseInfoDto;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.*;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+@Service
+@Slf4j
+public class MobileAppReleaseService {
+
+ private static final String DEFAULT_APK_NAME = "goldenchart-android.apk";
+ private static final DateTimeFormatter DT_FMT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.of("Asia/Seoul"));
+
+ @Value("${goldenchart.mobile-app.release-dir:data/mobile-releases}")
+ private String releaseDir;
+
+ @Value("${goldenchart.mobile-app.version:}")
+ private String configuredVersion;
+
+ @Value("${goldenchart.mobile-app.public-base-url:}")
+ private String configuredPublicBaseUrl;
+
+ public MobileAppReleaseInfoDto getInfo(String requestBaseUrl) {
+ String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl);
+ Optional apk = resolveApkPath();
+ if (apk.isEmpty()) {
+ return MobileAppReleaseInfoDto.builder()
+ .available(false)
+ .installPageUrl(buildInstallPageUrl(publicBaseUrl))
+ .build();
+ }
+ Path file = apk.get();
+ try {
+ long size = Files.size(file);
+ Instant updated = Files.getLastModifiedTime(file).toInstant();
+ String base = normalizeBase(publicBaseUrl);
+ String downloadUrl = base + "/api/mobile-app/download/android";
+ return MobileAppReleaseInfoDto.builder()
+ .available(true)
+ .fileName(file.getFileName().toString())
+ .version(configuredVersion != null && !configuredVersion.isBlank()
+ ? configuredVersion
+ : DT_FMT.format(updated))
+ .sizeBytes(size)
+ .updatedAt(DT_FMT.format(updated))
+ .downloadUrl(downloadUrl)
+ .installPageUrl(buildInstallPageUrl(publicBaseUrl))
+ .build();
+ } catch (IOException e) {
+ log.warn("[mobile-app] stat failed: {}", e.getMessage());
+ return MobileAppReleaseInfoDto.builder()
+ .available(false)
+ .installPageUrl(buildInstallPageUrl(publicBaseUrl))
+ .build();
+ }
+ }
+
+ public ResponseEntity downloadAndroid() {
+ Optional apk = resolveApkPath();
+ if (apk.isEmpty()) {
+ return ResponseEntity.notFound().build();
+ }
+ Path file = apk.get();
+ FileSystemResource resource = new FileSystemResource(file);
+ String filename = file.getFileName().toString();
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
+ .contentType(MediaType.parseMediaType("application/vnd.android.package-archive"))
+ .body(resource);
+ }
+
+ private Optional resolveApkPath() {
+ Path dir = Paths.get(releaseDir).toAbsolutePath().normalize();
+ if (!Files.isDirectory(dir)) {
+ log.debug("[mobile-app] release dir missing: {}", dir);
+ return Optional.empty();
+ }
+ Path preferred = dir.resolve(DEFAULT_APK_NAME);
+ if (Files.isRegularFile(preferred)) {
+ return Optional.of(preferred);
+ }
+ try (Stream stream = Files.list(dir)) {
+ return stream
+ .filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase().endsWith(".apk"))
+ .max(Comparator.comparing(p -> {
+ try {
+ return Files.getLastModifiedTime(p).toMillis();
+ } catch (IOException e) {
+ return 0L;
+ }
+ }));
+ } catch (IOException e) {
+ log.warn("[mobile-app] list dir failed: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private static String buildInstallPageUrl(String publicBaseUrl) {
+ return normalizeBase(publicBaseUrl) + "/install.html";
+ }
+
+ private String resolvePublicBaseUrl(String requestBaseUrl) {
+ if (configuredPublicBaseUrl != null && !configuredPublicBaseUrl.isBlank()) {
+ return configuredPublicBaseUrl.trim();
+ }
+ if (requestBaseUrl != null && !requestBaseUrl.isBlank()) {
+ return requestBaseUrl.trim();
+ }
+ return "http://exdev.co.kr";
+ }
+
+ private static String normalizeBase(String publicBaseUrl) {
+ if (publicBaseUrl == null || publicBaseUrl.isBlank()) {
+ return "http://exdev.co.kr";
+ }
+ return publicBaseUrl.replaceAll("/+$", "");
+ }
+}
diff --git a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java
index abfc56d..67b6919 100644
--- a/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java
+++ b/backend/src/main/java/com/goldenchart/service/StrategyConditionTimeframeService.java
@@ -67,6 +67,11 @@ public class StrategyConditionTimeframeService {
GcStrategy strategy = opt.get();
ensureDslRepaired(strategy);
+ Set fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
+ if (!fromFlowLayout.isEmpty()) {
+ return fromFlowLayout;
+ }
+
Set buyOut = new LinkedHashSet<>();
Set sellOut = new LinkedHashSet<>();
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
@@ -241,4 +246,33 @@ public class StrategyConditionTimeframeService {
out.add(ct);
}
}
+
+ /** flow_layout_json.startMeta — 편집기 Logic Expression·START 분봉 (DSL과 동기 저장) */
+ private Set collectFromFlowLayoutJson(String flowLayoutJson) {
+ Set out = new LinkedHashSet<>();
+ if (flowLayoutJson == null || flowLayoutJson.isBlank()) return out;
+ try {
+ JsonNode root = objectMapper.readTree(flowLayoutJson);
+ for (String side : new String[] { "buy", "sell" }) {
+ JsonNode startMeta = root.path(side).path("startMeta");
+ if (!startMeta.isObject()) continue;
+ startMeta.fields().forEachRemaining(entry -> {
+ JsonNode meta = entry.getValue();
+ JsonNode types = meta.path("candleTypes");
+ if (types.isArray() && !types.isEmpty()) {
+ for (JsonNode t : types) {
+ String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
+ if (!ct.isBlank()) out.add(ct);
+ }
+ } else {
+ String ct = LiveStrategyTimeframeService.normalize(meta.path("candleType").asText(""));
+ if (!ct.isBlank()) out.add(ct);
+ }
+ });
+ }
+ } catch (Exception e) {
+ log.warn("[StrategyTimeframes] flow_layout_json 파싱 실패: {}", e.getMessage());
+ }
+ return out;
+ }
}
diff --git a/backend/src/main/java/com/goldenchart/service/StrategyService.java b/backend/src/main/java/com/goldenchart/service/StrategyService.java
index 7a28758..e75b5b5 100644
--- a/backend/src/main/java/com/goldenchart/service/StrategyService.java
+++ b/backend/src/main/java/com/goldenchart/service/StrategyService.java
@@ -72,6 +72,9 @@ public class StrategyService {
sellJson != null
? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName)
: null);
+ if (dto.getFlowLayout() != null && !dto.getFlowLayout().isNull()) {
+ entity.setFlowLayoutJson(objectMapper.writeValueAsString(dto.getFlowLayout()));
+ }
} catch (Exception e) {
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
}
@@ -149,6 +152,8 @@ public class StrategyService {
dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson()));
if (e.getSellConditionJson() != null)
dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson()));
+ if (e.getFlowLayoutJson() != null && !e.getFlowLayoutJson().isBlank())
+ dto.setFlowLayout(objectMapper.readTree(e.getFlowLayoutJson()));
} catch (Exception ex) {
log.warn("전략 DSL 역직렬화 실패: {}", ex.getMessage());
}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 057cf4e..1eaf54f 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -104,12 +104,20 @@ goldenchart:
upload-dir: ${GC_VERIFICATION_UPLOAD_DIR:data/verification-images}
max-image-size-mb: 5
max-images-per-issue: 30
+ mobile-app:
+ release-dir: ${GC_MOBILE_APP_RELEASE_DIR:data/mobile-releases}
+ version: ${GC_MOBILE_APP_VERSION:}
+ # QR·APK 다운로드 절대 URL (로컬 개발 시에도 exdev 서버 APK 사용)
+ public-base-url: ${GC_MOBILE_APP_PUBLIC_BASE_URL:http://exdev.co.kr}
cors:
allowed-origins:
- http://localhost:5173
+ - http://localhost:5174
- http://localhost:3000
- http://localhost:80
- http://localhost
+ - capacitor://localhost
+ - https://localhost
# Firebase FCM (서비스 계정 JSON: classpath:firebase-service-account.json)
firebase:
diff --git a/backend/src/main/resources/db/migration/V43__strategy_flow_layout.sql b/backend/src/main/resources/db/migration/V43__strategy_flow_layout.sql
new file mode 100644
index 0000000..e7f90a0
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V43__strategy_flow_layout.sql
@@ -0,0 +1,3 @@
+-- 전략편집기 flow layout (START 분봉·노드 좌표·orphans 등) — localStorage 대신 DB 단일 소스
+ALTER TABLE gc_strategy
+ ADD COLUMN flow_layout_json JSON NULL COMMENT '전략편집기 UI 상태 {buy,sell}' AFTER sell_condition_json;
diff --git a/backend/src/main/resources/db/migration/V44__ui_preferences_json.sql b/backend/src/main/resources/db/migration/V44__ui_preferences_json.sql
new file mode 100644
index 0000000..742d586
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V44__ui_preferences_json.sql
@@ -0,0 +1,5 @@
+-- UI·편집기·가상투자 등 클라이언트 설정 (localStorage 대체)
+ALTER TABLE gc_app_settings
+ ADD COLUMN ui_preferences_json JSON NULL
+ COMMENT '프론트 UI 설정 통합 JSON'
+ AFTER trend_search_settings_json;
diff --git a/data/mobile-releases/README.md b/data/mobile-releases/README.md
new file mode 100644
index 0000000..93ae583
--- /dev/null
+++ b/data/mobile-releases/README.md
@@ -0,0 +1,64 @@
+# GoldenChart Android APK 배포
+
+APK는 **Git에 포함하지 않습니다.** 로컬에서 빌드 후 서버 `data/mobile-releases/`에 업로드합니다.
+
+## exdev.co.kr 업로드 (권장)
+
+```bash
+# 1) docker-compose 볼륨 변경을 서버에 반영 (최초 1회)
+git push origin main # Jenkins 자동 배포
+
+# 2) 로컬 APK 업로드
+chmod +x scripts/upload-android-apk.sh
+./scripts/upload-android-apk.sh
+
+# 빌드부터 한 번에
+./scripts/upload-android-apk.sh --build --version 1.0.0
+```
+
+서버 경로는 SSH로 `deploy.sh`의 `WORK_TREE`를 자동 탐색합니다 (기본: `/Users/aidev/apps/goldenChart`).
+
+로컬 오버라이드: `scripts/deploy-target.local.env`
+
+```bash
+REMOTE_PROJECT_DIR=/Users/aidev/apps/goldenChart
+PUBLIC_URL=http://exdev.co.kr
+```
+
+## 서버 파일 위치
+
+```
+/data/mobile-releases/goldenchart-android.apk
+```
+
+Docker backend가 `/app/data/mobile-releases`로 마운트해 제공합니다.
+
+- API: `GET /api/mobile-app/info`
+- 다운로드: `GET /api/mobile-app/download/android` (절대 URL: `http://exdev.co.kr`, HTTPS 443 미사용)
+- 설치 페이지: `http://exdev.co.kr/install.html`
+
+exdev.co.kr은 **HTTP(80)만** 열려 있습니다. QR URL에 `https://` 를 쓰면 휴대폰에서 연결되지 않습니다.
+
+## 로컬만 (개발 PC)
+
+```bash
+./scripts/copy-android-apk.sh
+docker compose up -d backend # 볼륨 마운트 후
+```
+
+## 환경 변수 (서버 .env)
+
+| 변수 | 설명 |
+|------|------|
+| `GC_MOBILE_APP_RELEASE_DIR` | 컨테이너 내부 APK 경로 (compose 기본값 사용) |
+| `GC_MOBILE_APP_VERSION` | 웹 UI 표시 버전 (`--version` 옵션으로 설정) |
+
+## 앱 API URL
+
+모바일 앱은 `app/.env`의 `VITE_API_BASE_URL`이 **빌드 시** 박힙니다. exdev 배포용:
+
+```env
+VITE_API_BASE_URL=http://exdev.co.kr/api
+```
+
+변경 후 `npm run cap:sync` → APK 재빌드 → 업로드.
diff --git a/docker-compose.yml b/docker-compose.yml
index dc05513..35d706c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -66,10 +66,14 @@ services:
DB_PASSWORD: ${MYSQL_PASSWORD:-analyzer}
GC_SECRETS_ENCRYPTION_KEY: ${GC_SECRETS_ENCRYPTION_KEY:-goldenchart-local-dev-key-change-me}
GC_VERIFICATION_UPLOAD_DIR: /app/data/verification-images
+ GC_MOBILE_APP_RELEASE_DIR: /app/data/mobile-releases
+ GC_MOBILE_APP_VERSION: ${GC_MOBILE_APP_VERSION:-}
+ GC_MOBILE_APP_PUBLIC_BASE_URL: ${GC_MOBILE_APP_PUBLIC_BASE_URL:-http://exdev.co.kr}
ports:
- "${BACKEND_PORT:-8080}:8080"
volumes:
- verification-images:/app/data/verification-images
+ - ./data/mobile-releases:/app/data/mobile-releases:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/indicators/list || exit 1"]
interval: 15s
diff --git a/frontend/.env.example b/frontend/.env.example
index fd12db3..1e08d7b 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -3,3 +3,6 @@
# Spring Boot 백엔드 URL (Vite 프록시 사용 시 /api 로 통일)
VITE_API_BASE_URL=/api
+
+# 로컬 dev: /api/mobile-app → exdev (QR·APK 다운로드 URL). vite.config.ts 기본값과 동일
+# VITE_MOBILE_APP_PROXY_TARGET=http://exdev.co.kr
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 79d2de1..96b3e20 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -109,6 +109,20 @@ server {
proxy_buffering off;
}
+ # ── 모바일 APK 다운로드 (대용량) ─────────────────────────────────────
+ location /api/mobile-app/download/ {
+ proxy_pass http://backend:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 600s;
+ proxy_send_timeout 600s;
+ proxy_buffering off;
+ client_max_body_size 200m;
+ }
+
# ── React SPA 폴백 ────────────────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e39a256..e243aa6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,11 +13,13 @@
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
+ "@types/qrcode": "^1.5.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
@@ -27,13 +29,13 @@
}
},
"node_modules/@babel/code-frame": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
- "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -42,9 +44,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.29.3",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
- "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -52,21 +54,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
- "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-compilation-targets": "^7.28.6",
- "@babel/helper-module-transforms": "^7.28.6",
- "@babel/helpers": "^7.28.6",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/traverse": "^7.29.0",
- "@babel/types": "^7.29.0",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -83,14 +85,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.29.1",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
- "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.29.0",
- "@babel/types": "^7.29.0",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -100,14 +102,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
- "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.6",
- "@babel/helper-validator-option": "^7.27.1",
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -117,9 +119,9 @@
}
},
"node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -127,29 +129,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
- "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
- "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -159,9 +161,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
- "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -169,9 +171,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -179,9 +181,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -189,9 +191,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -199,27 +201,27 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
- "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0"
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.29.3",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
- "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -229,13 +231,13 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
- "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -245,13 +247,13 @@
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
- "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -261,33 +263,33 @@
}
},
"node_modules/@babel/template": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
- "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
- "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0",
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -295,14 +297,14 @@
}
},
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1252,6 +1254,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "25.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
+ "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1259,10 +1271,20 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
- "version": "18.3.28",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
- "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "version": "18.3.29",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
+ "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1340,10 +1362,34 @@
"d3-zoom": "^3.0.0"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/baseline-browser-mapping": {
- "version": "2.10.29",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
- "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
+ "version": "2.10.32",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
+ "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1387,10 +1433,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
- "version": "1.0.30001792",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
- "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "version": "1.0.30001793",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
+ "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
"dev": true,
"funding": [
{
@@ -1414,6 +1469,35 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1551,13 +1635,34 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
- "version": "1.5.356",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
- "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==",
+ "version": "1.5.363",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz",
+ "integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==",
"dev": true,
"license": "ISC"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1634,6 +1739,19 @@
"node": ">=0.8.0"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1659,6 +1777,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/http-parser-js": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
@@ -1671,6 +1798,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1714,13 +1850,23 @@
},
"node_modules/lightweight-charts-indicators": {
"version": "0.4.1",
- "resolved": "https://registry.npmjs.org/lightweight-charts-indicators/-/lightweight-charts-indicators-0.4.1.tgz",
- "integrity": "sha512-l/ULwRz63xr1m9huktB0US/iginUQYDxEbeowqpPV/ZzLb9LRoSewlOEb0qJVygANUaXeaRmchPLDyMLmFYhAA==",
"license": "MIT",
"peerDependencies": {
"oakscriptjs": "^0.2.8"
}
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1769,16 +1915,17 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.44",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
- "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
+ "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/oakscriptjs": {
"version": "0.2.8",
- "resolved": "https://registry.npmjs.org/oakscriptjs/-/oakscriptjs-0.2.8.tgz",
- "integrity": "sha512-SRLR1QpnO3k8MNFzT5ZLEauWeULAcDTJ+0L0JXtshMvfDc5f8ezXVwgQ3D9Lv2g4aiUbJNmK+sNuZ8NKeC1I7Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -1787,6 +1934,51 @@
"lightweight-charts": "^5.0.0"
}
},
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1794,10 +1986,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
- "version": "8.5.14",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
- "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -1815,7 +2016,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1823,6 +2024,23 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -1864,6 +2082,21 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -1954,6 +2187,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/sockjs-client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
@@ -1992,6 +2231,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2006,6 +2271,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2139,6 +2411,32 @@
"node": ">=0.8.0"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -2146,6 +2444,41 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1545e90..5e98799 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,11 +14,13 @@
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
+ "qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
+ "@types/qrcode": "^1.5.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
diff --git a/frontend/public/install.html b/frontend/public/install.html
new file mode 100644
index 0000000..20b2d19
--- /dev/null
+++ b/frontend/public/install.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+ GoldenChart 앱 설치
+
+
+
+
+
GC
+
GoldenChart 앱 설치
+
가상매매·전략편집기·푸시 알림을 모바일에서 사용하세요.
+
+
+
+
+
+
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 794658e..eaed00d 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -11627,6 +11627,25 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
}
.tnl-row-chart:hover { background: var(--bg4); color: var(--text); }
+/* TopMenuBar 앱 다운로드 버튼 */
+.tmb-app-download-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text2);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.tmb-app-download-btn:hover {
+ background: var(--bg4);
+ color: var(--accent, #8b5cf6);
+}
+
/* TopMenuBar 알림 버튼 */
.tmb-notify-group {
display: inline-flex;
@@ -12517,3 +12536,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
border-radius: 10px;
background: rgba(255, 193, 7, 0.1);
}
+
+/* ── 앱 다운로드 모달 ── */
+.app-download-body { padding: 4px 2px 8px; }
+.app-download-muted { color: var(--text3); font-size: 13px; }
+.app-download-error { color: #ef4444; font-size: 13px; }
+.app-download-empty { text-align: center; padding: 16px 8px; }
+.app-download-empty-icon { font-size: 40px; margin-bottom: 8px; }
+.app-download-empty h3 { font-size: 16px; margin-bottom: 8px; }
+.app-download-empty p { font-size: 13px; color: var(--text2); line-height: 1.55; }
+.app-download-empty code { font-size: 11px; word-break: break-all; }
+.app-download-hero {
+ display: flex; align-items: center; gap: 14px;
+ padding: 12px 14px; margin-bottom: 16px;
+ background: var(--bg3); border-radius: 12px; border: 1px solid var(--border);
+}
+.app-download-app-icon {
+ width: 52px; height: 52px; border-radius: 14px;
+ background: linear-gradient(135deg, #8b5cf6, #2196f3);
+ display: flex; align-items: center; justify-content: center;
+ font-weight: 800; font-size: 18px; color: #fff; flex-shrink: 0;
+}
+.app-download-app-name { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
+.app-download-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text3); }
+.app-download-qr-block {
+ display: flex; flex-direction: column; align-items: center; gap: 14px;
+ padding: 16px; margin-bottom: 16px;
+ background: var(--bg2); border-radius: 14px; border: 1px solid var(--border);
+}
+.app-download-qr {
+ border-radius: 10px; background: #fff;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
+}
+.app-download-qr--placeholder {
+ width: 220px; height: 220px; display: flex; align-items: center; justify-content: center;
+ color: var(--text3); font-size: 13px;
+}
+.app-download-qr-help { text-align: center; max-width: 320px; }
+.app-download-qr-help strong { display: block; font-size: 14px; margin-bottom: 6px; }
+.app-download-qr-help p { font-size: 12px; color: var(--text2); line-height: 1.5; margin: 0 0 6px; }
+.app-download-url { font-size: 10px; word-break: break-all; opacity: 0.85; }
+.app-download-btn-primary {
+ width: 100%; padding: 14px; border: none; border-radius: 12px;
+ background: linear-gradient(135deg, #8b5cf6, #7c3aed);
+ color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
+}
+.app-download-btn-primary:hover { filter: brightness(1.06); }
+.app-download-steps {
+ margin-top: 14px; font-size: 12px; color: var(--text2);
+ border-top: 1px solid var(--border); padding-top: 12px;
+}
+.app-download-steps summary { cursor: pointer; font-weight: 600; color: var(--text); margin-bottom: 8px; }
+.app-download-steps ol { padding-left: 18px; line-height: 1.6; }
+.app-download-ios-note {
+ margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8acb8cb..ca60484 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -39,7 +39,7 @@ import {
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig';
import { normalizeIchimokuConfig } from './utils/ichimokuConfig';
import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
-import { getFavorites, saveFavorites, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
+import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types';
import type { ChartManager } from './utils/ChartManager';
@@ -105,6 +105,7 @@ import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
import { useIsMobile } from './hooks/useMediaQuery';
import MobileChartDock from './components/MobileChartDock';
import LoginModal from './components/LoginModal';
+import AppDownloadModal from './components/AppDownloadModal';
import SplashScreen from './components/SplashScreen';
import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth';
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
@@ -161,6 +162,7 @@ function App() {
isAppEntered() && !getAuthSession(),
);
const [loginOpen, setLoginOpen] = useState(false);
+ const [appDownloadOpen, setAppDownloadOpen] = useState(false);
// 전역 지표 파라미터 + 시각 설정 DB 동기화
const {
@@ -217,16 +219,8 @@ function App() {
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
- const [layoutDef, setLayoutDef] = useState(() => {
- const saved = localStorage.getItem('chartLayout');
- return LAYOUTS.find(l => l.id === saved) ?? LAYOUTS[0];
- });
- const [syncOptions, setSyncOptions] = useState(() => {
- try {
- const s = localStorage.getItem('chartLayoutSync');
- return s ? JSON.parse(s) : DEFAULT_SYNC;
- } catch { return DEFAULT_SYNC; }
- });
+ const [layoutDef, setLayoutDef] = useState(() => LAYOUTS[0]);
+ const [syncOptions, setSyncOptions] = useState(DEFAULT_SYNC);
const [activeSlot, setActiveSlot] = useState(0);
// 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용)
const [activeSlotIndicators, setActiveSlotIndicators] = useState([]);
@@ -387,13 +381,18 @@ function App() {
saveAppDef({ defaultLayoutId: def.id });
}, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]);
- // 레이아웃/싱크 변경 시 localStorage 저장
+ const appLayoutHydratedRef = useRef(false);
useEffect(() => {
- localStorage.setItem('chartLayout', layoutDef.id);
- }, [layoutDef]);
- useEffect(() => {
- localStorage.setItem('chartLayoutSync', JSON.stringify(syncOptions));
- }, [syncOptions]);
+ if (!appSettingsLoaded || appLayoutHydratedRef.current) return;
+ appLayoutHydratedRef.current = true;
+ if (appDefaults.layoutId) {
+ const def = LAYOUTS.find(l => l.id === appDefaults.layoutId);
+ if (def) setLayoutDef(def);
+ }
+ if (appDefaults.syncOptions) {
+ setSyncOptions(appDefaults.syncOptions);
+ }
+ }, [appSettingsLoaded, appDefaults.layoutId, appDefaults.syncOptions]);
// 다중 → 싱글 전환 시 activeSlotIndicators 초기화
useEffect(() => {
@@ -1118,10 +1117,6 @@ function App() {
return map;
}, [watchlist]);
- // ── 상태 저장 (localStorage) ───────────────────────────────────────────
- useEffect(() => {
- saveState({ symbol, timeframe, chartType, theme, indicators, watchlist, alertPrices: alerts.map(a => a.price) });
- }, [symbol, timeframe, chartType, theme, indicators, watchlist, alerts]);
// body 포털(알림 팝업 등) 테마 CSS 변수 동기화
useEffect(() => {
@@ -1201,10 +1196,10 @@ function App() {
},
onWatchlistLoad: (items: WatchlistItem[]) => {
const syms = items.map(i => i.symbol);
+ initFavoritesFromDb(syms);
if (syms.length > 0) {
setWatchlist(syms);
setFavoritesList(syms);
- saveFavorites(syms, false);
}
},
noAutoSave: !dbLoaded,
@@ -1672,6 +1667,7 @@ function App() {
theme={theme}
onPage={guardedSetMenuPage}
onTheme={handleThemeToggle}
+ onOpenAppDownload={() => setAppDownloadOpen(true)}
authUser={authUser}
guestMode={guestMode}
menuPermissions={menuPermissions}
@@ -1691,6 +1687,10 @@ function App() {
onSuccess={handleLoginSuccess}
/>
+ {appDownloadOpen && (
+ setAppDownloadOpen(false)} />
+ )}
+
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
{menuPage === 'dashboard' && (
{ goToMarketChart(m); setMenuPage('chart'); }} />
@@ -1900,6 +1900,7 @@ function App() {
setAppDownloadOpen(true)}
symbol={symbol} timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe} chartType={chartType}
theme={theme} mode={mode} logScale={logScale} showGrid={showGrid}
activeIndicators={toolbarActiveIndicators}
diff --git a/frontend/src/components/AppDownloadModal.tsx b/frontend/src/components/AppDownloadModal.tsx
new file mode 100644
index 0000000..c0d9be6
--- /dev/null
+++ b/frontend/src/components/AppDownloadModal.tsx
@@ -0,0 +1,151 @@
+/**
+ * 모바일 앱 설치 — APK 다운로드 URL·QR 코드
+ */
+import React, { useEffect, useMemo, useState } from 'react';
+import QRCode from 'qrcode';
+import DraggableModalFrame from './DraggableModalFrame';
+import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
+
+interface Props {
+ onClose: () => void;
+}
+
+function formatBytes(n?: number): string {
+ if (n == null || !Number.isFinite(n)) return '—';
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
+function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
+ if (!info?.available) return '';
+ return info.downloadUrl?.trim() ?? '';
+}
+
+const AppDownloadModal: React.FC = ({ onClose }) => {
+ const [info, setInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [qrDataUrl, setQrDataUrl] = useState(null);
+ const [error, setError] = useState(null);
+
+ const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void loadMobileAppReleaseInfo()
+ .then(data => {
+ if (!cancelled) setInfo(data);
+ })
+ .catch(() => {
+ if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ if (!downloadUrl) {
+ setQrDataUrl(null);
+ return;
+ }
+ let cancelled = false;
+ void QRCode.toDataURL(downloadUrl, {
+ width: 220,
+ margin: 2,
+ color: { dark: '#111827', light: '#ffffff' },
+ }).then(url => {
+ if (!cancelled) setQrDataUrl(url);
+ }).catch(() => {
+ if (!cancelled) setQrDataUrl(null);
+ });
+ return () => { cancelled = true; };
+ }, [downloadUrl]);
+
+ const handleDownload = () => {
+ if (!downloadUrl) return;
+ const a = document.createElement('a');
+ a.href = downloadUrl;
+ a.download = info?.fileName ?? 'goldenchart-android.apk';
+ a.rel = 'noopener';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ };
+
+ return (
+
+
+ {loading &&
앱 정보 불러오는 중…
}
+ {!loading && error &&
{error}
}
+ {!loading && !error && !info?.available && (
+
+
📱
+
설치 파일 준비 중
+
+ Android APK가 아직 서버에 업로드되지 않았습니다.
+
+ data/mobile-releases/goldenchart-android.apk 경로에 빌드 파일을 배치하세요.
+
+
+ )}
+ {!loading && info?.available && (
+ <>
+
+
GC
+
+
GoldenChart
+
+ {info.version && v{info.version}}
+ {info.sizeBytes != null && {formatBytes(info.sizeBytes)}}
+ {info.updatedAt && {info.updatedAt}}
+
+
+
+
+
+ {qrDataUrl ? (
+

+ ) : (
+
QR 생성 중…
+ )}
+
+
휴대폰 카메라로 QR 스캔
+
Android에서 APK 다운로드 페이지가 열립니다. 다운로드 후 설치하세요.
+
{downloadUrl}
+
+
+
+
+
+
+ 설치 방법 (Android)
+
+ - QR 코드를 스캔하거나 위 버튼으로 APK를 다운로드합니다.
+ - 「출처를 알 수 없는 앱」 설치 허용이 필요할 수 있습니다.
+ - 다운로드 완료 후 APK 파일을 탭하여 설치합니다.
+ - 설치 후 앱에서 API 서버 URL을 설정하세요 (설정 › 네트워크).
+
+
+
+
+ iOS는 App Store / TestFlight 배포가 필요합니다. 현재 QR·APK 설치는 Android 전용입니다.
+
+ >
+ )}
+
+
+ );
+};
+
+export default AppDownloadModal;
diff --git a/frontend/src/components/NotifyUiBindings.tsx b/frontend/src/components/NotifyUiBindings.tsx
index c354e9d..95fb0f4 100644
--- a/frontend/src/components/NotifyUiBindings.tsx
+++ b/frontend/src/components/NotifyUiBindings.tsx
@@ -13,6 +13,7 @@ interface NotifyTopMenuBarProps {
theme: Theme;
onPage: (page: MenuPage) => void;
onTheme: () => void;
+ onOpenAppDownload?: () => void;
authUser?: AuthSession | null;
onLoginClick?: () => void;
onLogout?: () => void;
@@ -47,8 +48,12 @@ export const NotifyTopMenuBar: React.FC = props => {
import Toolbar, { type ToolbarProps } from './Toolbar';
-export const ChartToolbarNotify: React.FC void }> = ({
+export const ChartToolbarNotify: React.FC void;
+ onOpenAppDownload?: () => void;
+}> = ({
onOpenNotifyList,
+ onOpenAppDownload,
...toolbarProps
}) => {
const { unreadCount } = useTradeNotification();
@@ -57,6 +62,7 @@ export const ChartToolbarNotify: React.FC
);
};
diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx
index bf3a27c..ac4ab57 100644
--- a/frontend/src/components/StrategyEditorPage.tsx
+++ b/frontend/src/components/StrategyEditorPage.tsx
@@ -5,7 +5,8 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react';
import DraggableModalFrame from './DraggableModalFrame';
import type { Theme } from '../types/index';
-import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
+import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
+import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import type { LogicNode } from '../utils/strategyTypes';
import {
buildStrategyEditorDefFromSettings,
@@ -35,7 +36,7 @@ import {
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
-import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
+import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
import {
START_NODE_ID,
defaultStartMeta,
@@ -48,10 +49,10 @@ import {
} from '../utils/strategyPaletteStorage';
import {
emptySignalFlowLayout,
- loadStrategyFlowLayout,
- migrateStrategyFlowLayout,
- saveStrategyFlowLayout,
- deleteStrategyFlowLayout,
+ buildStrategyFlowLayoutStore,
+ normalizeStrategyFlowLayout,
+ readLegacyFlowLayoutFromLocalStorage,
+ clearLegacyFlowLayoutFromLocalStorage,
type SignalFlowLayoutSnapshot,
type FlowLayoutChangePayload,
type StrategyFlowLayoutStore,
@@ -97,21 +98,6 @@ interface Props {
theme: Theme;
}
-function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
- const stored = loadStrategyFlowLayout(strategyKey);
- const snap = stored?.[tab];
- if (!snap) return emptySignalFlowLayout();
- return {
- positions: snap.positions ?? {},
- edgeHandles: snap.edgeHandles ?? {},
- orphans: snap.orphans ?? [],
- startMeta: snap.startMeta ?? defaultStartMeta(),
- extraStartIds: snap.extraStartIds ?? [],
- extraRoots: snap.extraRoots ?? {},
- startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
- };
-}
-
function toEditorState(
root: LogicNode | null,
layout: Pick,
@@ -136,6 +122,28 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
};
}
+function applyFlowLayoutFromStore(
+ stored: StrategyFlowLayoutStore | null,
+ setBuyLayout: React.Dispatch>>,
+ setSellLayout: React.Dispatch>>,
+ setBuyOrphans: React.Dispatch>,
+ setSellOrphans: React.Dispatch>,
+) {
+ if (stored) {
+ setBuyLayout(normalizeTabLayoutState(stored.buy));
+ setSellLayout(normalizeTabLayoutState(stored.sell));
+ setBuyOrphans(stored.buy.orphans ?? []);
+ setSellOrphans(stored.sell.orphans ?? []);
+ } else {
+ const emptyBuy = emptySignalFlowLayout();
+ const emptySell = emptySignalFlowLayout();
+ setBuyLayout(normalizeTabLayoutState(emptyBuy));
+ setSellLayout(normalizeTabLayoutState(emptySell));
+ setBuyOrphans([]);
+ setSellOrphans([]);
+ }
+}
+
export default function StrategyEditorPage({ theme }: Props) {
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo(
@@ -164,44 +172,32 @@ export default function StrategyEditorPage({ theme }: Props) {
const [descOpen, setDescOpen] = useState(false);
const [deleteId, setDeleteId] = useState(null);
const [selectedNodeId, setSelectedNodeId] = useState(null);
- const initialDraftBuy = readTabLayout('draft', 'buy');
- const initialDraftSell = readTabLayout('draft', 'sell');
- const [buyOrphans, setBuyOrphans] = useState(() => initialDraftBuy.orphans ?? []);
- const [sellOrphans, setSellOrphans] = useState(() => initialDraftSell.orphans ?? []);
+ const [buyOrphans, setBuyOrphans] = useState([]);
+ const [sellOrphans, setSellOrphans] = useState([]);
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
const [saveToast, setSaveToast] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const terminalHeightRef = useRef(terminalHeight);
- const [buyLayout, setBuyLayout] = useState(() => ({
- positions: initialDraftBuy.positions,
- edgeHandles: initialDraftBuy.edgeHandles,
- startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
- extraStartIds: initialDraftBuy.extraStartIds ?? [],
- extraRoots: initialDraftBuy.extraRoots ?? {},
- startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
- }));
- const [sellLayout, setSellLayout] = useState(() => ({
- positions: initialDraftSell.positions,
- edgeHandles: initialDraftSell.edgeHandles,
- startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
- extraStartIds: initialDraftSell.extraStartIds ?? [],
- extraRoots: initialDraftSell.extraRoots ?? {},
- startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
- }));
+ const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
+ const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const buyLayoutRef = useRef(buyLayout);
const sellLayoutRef = useRef(sellLayout);
const buyConditionRef = useRef(buyCondition);
const sellConditionRef = useRef(sellCondition);
+ const buyOrphansRef = useRef(buyOrphans);
+ const sellOrphansRef = useRef(sellOrphans);
buyLayoutRef.current = buyLayout;
sellLayoutRef.current = sellLayout;
buyConditionRef.current = buyCondition;
sellConditionRef.current = sellCondition;
- const timeframeAutosaveRef = useRef(null);
+ buyOrphansRef.current = buyOrphans;
+ sellOrphansRef.current = sellOrphans;
const layoutRevisionRef = useRef(0);
- const persistLayoutTimerRef = useRef(null);
+ const strategyAutosaveTimerRef = useRef(null);
const layoutPersistReadyRef = useRef(false);
+ const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
terminalHeightRef.current = terminalHeight;
@@ -268,20 +264,67 @@ export default function StrategyEditorPage({ theme }: Props) {
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
- const persistFlowLayout = useCallback((strategyKey: string) => {
- saveStrategyFlowLayout(strategyKey, {
- buy: { ...buyLayoutRef.current, orphans: buyOrphans },
- sell: { ...sellLayoutRef.current, orphans: sellOrphans },
- });
- }, [buyOrphans, sellOrphans]);
+ const buildCurrentFlowLayout = useCallback(
+ (): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
+ buyLayoutRef.current,
+ sellLayoutRef.current,
+ buyOrphansRef.current,
+ sellOrphansRef.current,
+ ),
+ [],
+ );
- const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
- if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
- persistLayoutTimerRef.current = window.setTimeout(() => {
- persistLayoutTimerRef.current = null;
- persistFlowLayout(strategyKey);
- }, 150);
- }, [persistFlowLayout]);
+ const persistStrategyToDb = useCallback(async () => {
+ if (selectedId == null || !stratName.trim()) return;
+ const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
+ const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
+ const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
+ const encodedBuy = encodeConditionForSave(aligned.buy);
+ const encodedSell = encodeConditionForSave(aligned.sell);
+ if (!encodedBuy && !encodedSell) return;
+ const flowLayout = buildCurrentFlowLayout();
+ const saved = await persistStrategyEditorState(
+ selectedId,
+ stratName,
+ stratDesc,
+ aligned.buy,
+ aligned.sell,
+ flowLayout,
+ );
+ setStrategies(prev => prev.map(s => s.id === selectedId
+ ? {
+ ...s,
+ buyCondition: encodedBuy,
+ sellCondition: encodedSell,
+ flowLayout,
+ updatedAt: saved?.updatedAt ?? s.updatedAt,
+ }
+ : s));
+ }, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
+
+ const flushStrategyPersist = useCallback(() => {
+ if (strategyAutosaveTimerRef.current != null) {
+ window.clearTimeout(strategyAutosaveTimerRef.current);
+ strategyAutosaveTimerRef.current = null;
+ }
+ if (selectedId == null || !stratName.trim()) return;
+ void persistStrategyToDb().catch(err => {
+ console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
+ });
+ }, [selectedId, stratName, persistStrategyToDb]);
+
+ const scheduleStrategyPersist = useCallback(() => {
+ if (selectedId == null || !stratName.trim()) return;
+ if (strategyAutosaveTimerRef.current != null) {
+ window.clearTimeout(strategyAutosaveTimerRef.current);
+ }
+ strategyAutosaveTimerRef.current = window.setTimeout(() => {
+ strategyAutosaveTimerRef.current = null;
+ void persistStrategyToDb().catch(err => {
+ console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
+ });
+ }, STRATEGY_AUTOSAVE_MS);
+ }, [selectedId, stratName, persistStrategyToDb]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
@@ -318,50 +361,11 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
}));
bumpLayoutSeed(layoutStrategyKey, signalTab);
- }, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
+ if (selectedId != null && stratName.trim()) scheduleStrategyPersist();
+ }, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
- const emptyBuy = emptySignalFlowLayout();
- const emptySell = emptySignalFlowLayout();
- setBuyLayout(normalizeTabLayoutState(emptyBuy));
- setSellLayout(normalizeTabLayoutState(emptySell));
- setBuyOrphans([]);
- setSellOrphans([]);
- saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
- bumpLayoutSeed(strategyKey, tab);
- }, [bumpLayoutSeed]);
-
- const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
- const stored = loadStrategyFlowLayout(strategyKey);
- if (stored) {
- const nextBuy = {
- positions: stored.buy.positions ?? {},
- edgeHandles: stored.buy.edgeHandles ?? {},
- startMeta: stored.buy.startMeta ?? defaultStartMeta(),
- extraStartIds: stored.buy.extraStartIds ?? [],
- extraRoots: stored.buy.extraRoots ?? {},
- startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
- };
- const nextSell = {
- positions: stored.sell.positions ?? {},
- edgeHandles: stored.sell.edgeHandles ?? {},
- startMeta: stored.sell.startMeta ?? defaultStartMeta(),
- extraStartIds: stored.sell.extraStartIds ?? [],
- extraRoots: stored.sell.extraRoots ?? {},
- startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
- };
- setBuyLayout(nextBuy);
- setSellLayout(nextSell);
- setBuyOrphans(stored.buy.orphans ?? []);
- setSellOrphans(stored.sell.orphans ?? []);
- } else {
- const emptyBuy = emptySignalFlowLayout();
- const emptySell = emptySignalFlowLayout();
- setBuyLayout(normalizeTabLayoutState(emptyBuy));
- setSellLayout(normalizeTabLayoutState(emptySell));
- setBuyOrphans([]);
- setSellOrphans([]);
- }
+ applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans);
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
@@ -372,24 +376,34 @@ export default function StrategyEditorPage({ theme }: Props) {
};
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
else setSellLayout(prev => ({ ...prev, ...patch }));
- schedulePersistFlowLayout(layoutStrategyKey);
- }, [layoutStrategyKey, schedulePersistFlowLayout]);
+ scheduleStrategyPersist();
+ }, [scheduleStrategyPersist]);
+
+ const handleRootChange = useCallback((root: LogicNode | null) => {
+ setCurrentRoot(root);
+ scheduleStrategyPersist();
+ }, [setCurrentRoot, scheduleStrategyPersist]);
+
+ const handleOrphansChange = useCallback((orphans: LogicNode[]) => {
+ setCurrentOrphans(orphans);
+ scheduleStrategyPersist();
+ }, [setCurrentOrphans, scheduleStrategyPersist]);
const handleStartMetaChange = useCallback((meta: Record) => {
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
- schedulePersistFlowLayout(layoutStrategyKey);
- }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
+ scheduleStrategyPersist();
+ }, [setCurrentLayout, scheduleStrategyPersist]);
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
- schedulePersistFlowLayout(layoutStrategyKey);
+ scheduleStrategyPersist();
bumpLayoutSeed(layoutStrategyKey, signalTab);
- }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
+ }, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
const handleExtraRootsChange = useCallback((roots: Record) => {
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
- schedulePersistFlowLayout(layoutStrategyKey);
- }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
+ scheduleStrategyPersist();
+ }, [setCurrentLayout, scheduleStrategyPersist]);
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
setCurrentRoot(next.root);
@@ -400,24 +414,8 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: next.extraRoots,
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
}));
- schedulePersistFlowLayout(layoutStrategyKey);
- }, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
-
- const scheduleTimeframePersist = useCallback(() => {
- if (selectedId == null || !stratName.trim()) return;
- if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
- const id = selectedId;
- const name = stratName;
- const desc = stratDesc;
- timeframeAutosaveRef.current = window.setTimeout(() => {
- timeframeAutosaveRef.current = null;
- const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
- const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
- void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
- console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
- });
- }, 700);
- }, [selectedId, stratName, stratDesc]);
+ scheduleStrategyPersist();
+ }, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (startId === START_NODE_ID) {
@@ -438,20 +436,16 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: synced.sell.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
}));
- schedulePersistFlowLayout(layoutStrategyKey);
- scheduleTimeframePersist();
+ scheduleStrategyPersist();
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
- scheduleTimeframePersist();
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
- layoutStrategyKey,
- schedulePersistFlowLayout,
- scheduleTimeframePersist,
+ scheduleStrategyPersist,
signalTab,
]);
@@ -461,18 +455,18 @@ export default function StrategyEditorPage({ theme }: Props) {
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
if (editorMode === 'graph') layoutFlushRef.current?.();
- persistFlowLayout(layoutStrategyKey);
+ flushStrategyPersist();
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
setSignalTab(tab);
setSelectedNodeId(null);
- }, [layoutStrategyKey, persistFlowLayout, editorMode]);
+ }, [layoutStrategyKey, flushStrategyPersist, editorMode]);
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
if (mode === editorMode) return;
if (editorMode === 'graph') {
layoutFlushRef.current?.();
- persistFlowLayout(layoutStrategyKey);
+ flushStrategyPersist();
}
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
@@ -480,7 +474,7 @@ export default function StrategyEditorPage({ theme }: Props) {
setEditorMode(mode);
saveEditorMode(mode);
setSelectedNodeId(null);
- }, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
+ }, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
const layoutSeed = useMemo((): FlowLayoutSeed => {
const source = signalTab === 'buy' ? buyLayout : sellLayout;
@@ -491,17 +485,29 @@ export default function StrategyEditorPage({ theme }: Props) {
};
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
- useEffect(() => () => {
- if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
- }, []);
+ const persistStrategyToDbRef = useRef(persistStrategyToDb);
+ persistStrategyToDbRef.current = persistStrategyToDb;
+ useEffect(() => {
+ const id = selectedId;
+ const name = stratName;
+ return () => {
+ if (strategyAutosaveTimerRef.current != null) {
+ window.clearTimeout(strategyAutosaveTimerRef.current);
+ strategyAutosaveTimerRef.current = null;
+ }
+ if (id != null && name.trim()) {
+ void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
+ }
+ };
+ }, [selectedId, stratName]);
useEffect(() => {
if (!layoutPersistReadyRef.current) {
layoutPersistReadyRef.current = true;
return;
}
- schedulePersistFlowLayout(layoutStrategyKey);
- }, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
+ scheduleStrategyPersist();
+ }, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
@@ -514,6 +520,7 @@ export default function StrategyEditorPage({ theme }: Props) {
description: s.description,
buyCondition: s.buyCondition as LogicNode | null ?? null,
sellCondition: s.sellCondition as LogicNode | null ?? null,
+ flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
enabled: s.enabled ?? true,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
@@ -553,7 +560,10 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleSelectStrategy = (s: StrategyDto) => {
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
- const stored = loadStrategyFlowLayout(String(s.id));
+ let stored = normalizeStrategyFlowLayout(s.flowLayout);
+ if (!stored) {
+ stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
+ }
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
@@ -600,6 +610,58 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setSelectedNodeId(null);
bumpLayoutSeed(String(s.id), signalTab);
+
+ if (!s.flowLayout && stored) {
+ void (async () => {
+ try {
+ const aligned = alignBuySellStartCandleTypesForSave(
+ toEditorState(buySynced.root, {
+ startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
+ extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
+ extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
+ startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
+ }),
+ toEditorState(sellSynced.root, {
+ startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
+ extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
+ extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
+ startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
+ }),
+ );
+ await saveStrategy({
+ id: s.id,
+ name: s.name,
+ description: s.description ?? '',
+ buyCondition: encodeConditionForSave(aligned.buy),
+ sellCondition: encodeConditionForSave(aligned.sell),
+ flowLayout: stored,
+ enabled: s.enabled ?? true,
+ });
+ clearLegacyFlowLayoutFromLocalStorage(String(s.id));
+ setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
+ } catch {
+ /* ignore */
+ }
+ })();
+ }
+
+ // flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
+ void (async () => {
+ const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
+ if (!synced) return;
+ try {
+ const fresh = await loadStrategy(s.id);
+ if (!fresh) return;
+ const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
+ const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
+ const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
+ const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
+ setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
+ setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
+ } catch {
+ /* ignore */
+ }
+ })();
};
const handleNew = () => {
@@ -620,39 +682,59 @@ export default function StrategyEditorPage({ theme }: Props) {
}
setSaveNameError(false);
if (editorMode === 'graph') layoutFlushRef.current?.();
+ if (strategyAutosaveTimerRef.current != null) {
+ window.clearTimeout(strategyAutosaveTimerRef.current);
+ strategyAutosaveTimerRef.current = null;
+ }
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true);
try {
+ const flowLayout = buildCurrentFlowLayout();
const payload: ApiStrategyDto = {
id: selectedId ?? undefined,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
+ flowLayout,
enabled: true,
};
const saved = await saveStrategy(payload);
const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now();
- const wasDraft = selectedId == null;
setStrategies(prev => {
const existing = prev.find(s => s.id === selectedId);
if (existing && selectedId) {
return prev.map(s => s.id === selectedId
- ? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
+ ? {
+ ...s,
+ id: dbId,
+ name: stratName,
+ description: stratDesc,
+ buyCondition: encodedBuy,
+ sellCondition: encodedSell,
+ flowLayout,
+ updatedAt: saved?.updatedAt ?? now,
+ }
: s);
}
setSelectedId(dbId);
- return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
+ return [...prev, {
+ id: dbId,
+ name: stratName,
+ description: stratDesc,
+ buyCondition: encodedBuy,
+ sellCondition: encodedSell,
+ flowLayout,
+ enabled: true,
+ createdAt: saved?.createdAt ?? now,
+ updatedAt: saved?.updatedAt ?? now,
+ }];
});
if (!selectedId) setSelectedId(dbId);
- if (wasDraft) {
- migrateStrategyFlowLayout('draft', String(dbId));
- }
- persistFlowLayout(String(dbId));
bumpLayoutSeed(String(dbId), signalTab);
setSaveOpen(false);
setSaveToast(true);
@@ -668,7 +750,7 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleDeleteConfirm = async () => {
if (!deleteId) return;
try { await deleteStrategy(deleteId); } catch { /* local */ }
- deleteStrategyFlowLayout(String(deleteId));
+ clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
setStrategies(prev => prev.filter(s => s.id !== deleteId));
if (selectedId === deleteId) handleNew();
setDeleteOpen(false);
@@ -696,7 +778,6 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setBuyOrphans(layout.buy.orphans ?? []);
setSellOrphans(layout.sell.orphans ?? []);
- saveStrategyFlowLayout('draft', layout);
} else {
resetFlowLayout('draft', tab);
}
@@ -773,7 +854,7 @@ export default function StrategyEditorPage({ theme }: Props) {
}
const items = strategies.map(s => strategyDtoToListExportItem(
s,
- loadStrategyFlowLayout(String(s.id)),
+ s.flowLayout ?? null,
));
downloadStrategyJson(
`전략목록_${new Date().toISOString().slice(0, 10)}`,
@@ -794,7 +875,6 @@ export default function StrategyEditorPage({ theme }: Props) {
const baseId = Date.now();
const imported = result.data.strategies.map((item, index) => {
const id = baseId + index;
- if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
return listImportItemToStrategyDto(item, id);
});
setStrategies(prev => [...prev, ...imported]);
@@ -819,7 +899,8 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!root) setCurrentRoot(newNode);
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
else setCurrentRoot(mergeAtRoot(root, newNode, false));
- }, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
+ scheduleStrategyPersist();
+ }, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
const applyPaletteItem = useCallback((item: PaletteItem) => {
const composite = item.kind === 'composite';
@@ -827,7 +908,8 @@ export default function StrategyEditorPage({ theme }: Props) {
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
- }, [signalTab, DEF, currentRoot, setCurrentRoot]);
+ scheduleStrategyPersist();
+ }, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
@@ -846,9 +928,10 @@ export default function StrategyEditorPage({ theme }: Props) {
resetFlowLayout(layoutStrategyKey, targetTab);
if (targetTab !== signalTab) setSignalTab(targetTab);
+ scheduleStrategyPersist();
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
- }, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
+ }, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
@@ -1068,10 +1151,10 @@ export default function StrategyEditorPage({ theme }: Props) {
theme={theme}
root={currentRoot}
orphans={currentOrphans}
- onOrphansChange={setCurrentOrphans}
+ onOrphansChange={handleOrphansChange}
def={DEF}
signalTab={signalTab}
- onChange={setCurrentRoot}
+ onChange={handleRootChange}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
layoutSeed={layoutSeed}
@@ -1097,14 +1180,14 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!selectedNodeId) return;
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
if (inOrphans) {
- setCurrentOrphans(currentOrphans.map(o => (
+ handleOrphansChange(currentOrphans.map(o => (
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
)));
return;
}
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
if (findLogicNode(currentRoot, [], selectedNodeId)) {
- setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
+ handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
return;
}
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
@@ -1131,7 +1214,7 @@ export default function StrategyEditorPage({ theme }: Props) {
onEditorStateChange={handleEditorStateChange}
onAddStart={handleAddStartSection}
orphans={currentOrphans}
- onOrphansChange={setCurrentOrphans}
+ onOrphansChange={handleOrphansChange}
/>
)}
diff --git a/frontend/src/components/StrategyPage.tsx b/frontend/src/components/StrategyPage.tsx
index cb535e7..61ade15 100644
--- a/frontend/src/components/StrategyPage.tsx
+++ b/frontend/src/components/StrategyPage.tsx
@@ -66,11 +66,8 @@ interface ValidationResult {
let _cnt = 0;
const genId = () => `n_${++_cnt}_${Date.now()}`;
-const STORAGE_KEY = 'gc_strat_v2';
-const loadStratsLocal = (): StrategyDto[] => {
- try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
-};
-const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
+const loadStratsLocal = (): StrategyDto[] => [];
+const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx
index 128ee3b..114d8c1 100644
--- a/frontend/src/components/Toolbar.tsx
+++ b/frontend/src/components/Toolbar.tsx
@@ -40,30 +40,18 @@ import LayoutPicker from './LayoutPicker';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
-const LOCAL_BT_KEY = 'gc_strat_v2';
-
-interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; }
+interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
function loadBtStrategies(): Promise {
return loadStrategies()
- .then(list => {
- if (list && list.length > 0) {
- return list.map(s => ({ id: s.id, name: s.name, source: 'db' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
- }
- // localStorage 폴백
- try {
- const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
- JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
- return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
- } catch { return []; }
- })
- .catch(() => {
- try {
- const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
- JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
- return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
- } catch { return []; }
- });
+ .then(list => (list ?? []).map(s => ({
+ id: s.id,
+ name: s.name,
+ source: 'db' as const,
+ buyCondition: s.buyCondition,
+ sellCondition: s.sellCondition,
+ })))
+ .catch(() => []);
}
export interface ToolbarProps {
@@ -98,6 +86,8 @@ export interface ToolbarProps {
/** 매매 시그널 알림 (미확인 개수) */
tradeNotifyUnread?: number;
onOpenTradeNotifications?: () => void;
+ /** 모바일 앱 다운로드 */
+ onOpenAppDownload?: () => void;
/** 자석모드 현재 상태 */
magnetMode?: 'off' | 'weak' | 'strong';
/** 자석모드 토글 (off↔strong) */
@@ -233,6 +223,14 @@ const IcBell = () => (
);
+/** 모바일 앱 다운로드 */
+const IcAppDownload = () => (
+
+);
/** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */
const IcTradeSignal = () => (
)}
+ {onOpenAppDownload && (
+
+ )}
{onOpenTradeNotifications && (