From 6d26ed36f14edc46eb5d7aba38995198dc061d35 Mon Sep 17 00:00:00 2001 From: Mol_ Date: Mon, 8 Dec 2025 13:35:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E3=82=B7=E3=83=A5=E3=83=9F=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E7=94=BB=E9=9D=A2=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 簡単な機能追加 バックエンドとの結合を考えず、簡単な処理をこちらで実装。 --- backendInfo/simulation_logic.py | 155 +++++++ ...343\202\271\346\203\205\345\240\261app.py" | 195 ++++++++ flutter_newokanelab/lib/core/constants.dart | 7 +- flutter_newokanelab/lib/main.dart | 165 +++---- .../lib/models/investment_models.dart | 28 ++ .../lib/screens/home_screen.dart | 172 +++++++ .../lib/services/simulation_service.dart | 49 ++ .../lib/widgets/input_section.dart | 423 ++++++++++++++++++ .../lib/widgets/results_section.dart | 286 ++++++++++++ flutter_newokanelab/pubspec.lock | 24 + flutter_newokanelab/pubspec.yaml | 67 +-- 11 files changed, 1409 insertions(+), 162 deletions(-) create mode 100644 backendInfo/simulation_logic.py create mode 100644 "backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" create mode 100644 flutter_newokanelab/lib/models/investment_models.dart create mode 100644 flutter_newokanelab/lib/screens/home_screen.dart create mode 100644 flutter_newokanelab/lib/services/simulation_service.dart create mode 100644 flutter_newokanelab/lib/widgets/input_section.dart create mode 100644 flutter_newokanelab/lib/widgets/results_section.dart diff --git a/backendInfo/simulation_logic.py b/backendInfo/simulation_logic.py new file mode 100644 index 00000000..3c0cd0b1 --- /dev/null +++ b/backendInfo/simulation_logic.py @@ -0,0 +1,155 @@ +import pandas as pd +from sqlalchemy import create_engine, text +import urllib.parse + +# DB設定 +MYSQL_USER = 'root' +MYSQL_PASSWORD = 'root' +MYSQL_HOST = 'localhost' +MYSQL_DB = 'trade_sim' + +def get_db_engine(): + encoded_password = urllib.parse.quote_plus(MYSQL_PASSWORD) + conn_str = f"mysql+pymysql://{MYSQL_USER}:{encoded_password}@{MYSQL_HOST}/{MYSQL_DB}?charset=utf8mb4" + return create_engine(conn_str) + +def get_companies_list(): + """ フロントのプルダウン用に企業リストを返す """ + engine = get_db_engine() + query = "SELECT ticker_code, company_name FROM companies" + df = pd.read_sql(query, engine) + return df.to_dict(orient='records') + +def run_simulation_logic(data): + """ + シミュレーション実行のコアロジック + data = { + "initial_investment": 1000000, + "monthly_investment": 50000, + "tickers": ["7203.T", "AAPL"], + "start_date": "2015-01-01", + "end_date": "2024-12-31" + } + """ + engine = get_db_engine() + + tickers = data.get('tickers', []) + start_date = data.get('start_date', '2015-01-01') + end_date = data.get('end_date', '2025-12-31') + initial_inv = float(data.get('initial_investment', 0)) + monthly_inv = float(data.get('monthly_investment', 0)) + + if not tickers: + return {"error": "No tickers selected"} + + # ------------------------------------------------- + # 1. 株価データの取得 + # ------------------------------------------------- + # 選択された銘柄の期間中のデータを取得 + tickers_str = "', '".join(tickers) + query_stock = f""" + SELECT date, ticker_code, close + FROM factstock_daily + WHERE ticker_code IN ('{tickers_str}') + AND date BETWEEN '{start_date}' AND '{end_date}' + ORDER BY date ASC + """ + df = pd.read_sql(query_stock, engine) + + # 日付をDatetime型に変換 + df['date'] = pd.to_datetime(df['date']) + + # ピボットテーブル化(行:日付, 列:銘柄, 値:終値) + df_pivot = df.pivot(index='date', columns='ticker_code', values='close') + + # 欠損値は前日の値で埋める(土日祝対策) + df_pivot = df_pivot.fillna(method='ffill') + # それでも無い(上場前など)は0にするか除外するが、今回はそのまま + + # ------------------------------------------------- + # 2. シミュレーション計算 (簡易版: 等分投資) + # ------------------------------------------------- + # 毎月の積立額を銘柄数で割る + inv_per_ticker = monthly_inv / len(tickers) + initial_per_ticker = initial_inv / len(tickers) + + # 保有株数管理 + holdings = {t: 0.0 for t in tickers} + + # 初期投資 (開始日時点の株価で購入) + if not df_pivot.empty: + first_date = df_pivot.index[0] + for t in tickers: + if t in df_pivot.columns and pd.notna(df_pivot.loc[first_date, t]): + price = df_pivot.loc[first_date, t] + if price > 0: + holdings[t] += initial_per_ticker / price + + # 日次ループ用の結果リスト + daily_result = [] + + # 月次積立の判定用 (YYYY-MMが変わったら投資) + last_month = None + + total_invested = initial_inv + + for date, row in df_pivot.iterrows(): + current_month = date.strftime('%Y-%m') + + # 月が変わったら積立投資 (毎月1回) + if last_month and current_month != last_month: + total_invested += monthly_inv + for t in tickers: + if t in row and pd.notna(row[t]) and row[t] > 0: + holdings[t] += inv_per_ticker / row[t] + + last_month = current_month + + # 現在の資産評価額計算 + current_total_value = 0 + breakdown = {} + + for t in tickers: + if t in row and pd.notna(row[t]): + val = holdings[t] * row[t] + current_total_value += val + breakdown[t] = val + + daily_result.append({ + "date": date.strftime('%Y-%m-%d'), + "total_assets": round(current_total_value), + "invested_amount": round(total_invested), + # "breakdown": breakdown # 内訳が必要ならコメントアウト解除 + }) + + # ------------------------------------------------- + # 3. 関連イベントの取得 + # ------------------------------------------------- + # チャートに表示するため、その期間の選択銘柄 + 市場全体のイベントを取得 + query_events = f""" + SELECT event_date, ticker_code, title, description, sentiment_score + FROM fact_events + WHERE (ticker_code IN ('{tickers_str}') OR ticker_code IS NULL) + AND event_date BETWEEN '{start_date}' AND '{end_date}' + ORDER BY event_date ASC + """ + df_events = pd.read_sql(query_events, engine) + events_list = [] + for _, row in df_events.iterrows(): + events_list.append({ + "date": row['event_date'].strftime('%Y-%m-%d'), + "ticker": row['ticker_code'] if row['ticker_code'] else "Market", + "title": row['title'], + "description": row['description'], + "sentiment": row['sentiment_score'] + }) + + return { + "graph_data": daily_result, + "events": events_list, + "summary": { + "final_assets": daily_result[-1]["total_assets"] if daily_result else 0, + "total_invested": daily_result[-1]["invested_amount"] if daily_result else 0, + "return_rate": round((daily_result[-1]["total_assets"] / daily_result[-1]["invested_amount"] * 100) - 100, 2) if daily_result and daily_result[-1]["invested_amount"] > 0 else 0 + } + } \ No newline at end of file diff --git "a/backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" "b/backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" new file mode 100644 index 00000000..d93bf55f --- /dev/null +++ "b/backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" @@ -0,0 +1,195 @@ +from flask import Flask, request, jsonify +from flask_cors import CORS +from flasgger import Swagger +import backendInfo.simulation_logic as simulation_logic + +app = Flask(__name__) +CORS(app) + +# Swaggerの設定 +app.config['SWAGGER'] = { + 'title': 'Trade Sim API', + 'uiversion': 3 +} +swagger = Swagger(app) + +@app.route('/api/health', methods=['GET']) +def health_check(): + """ + ヘルスチェック用API + --- + responses: + 200: + description: サーバー稼働状況 + schema: + type: object + properties: + status: + type: string + example: ok + message: + type: string + example: Backend is running! + """ + return jsonify({"status": "ok", "message": "Backend is running!"}) + +@app.route('/api/companies', methods=['GET']) +def get_companies(): + """ + 企業リスト取得API + --- + description: データベースに登録されている全企業のリストを返します + responses: + 200: + description: 成功 + schema: + type: array + items: + type: object + properties: + ticker_code: + type: string + example: 7203.T + company_name: + type: string + example: トヨタ自動車 + """ + try: + companies = simulation_logic.get_companies_list() + return jsonify(companies) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@app.route('/api/simulate', methods=['POST']) +def simulate(): + """ + シミュレーション実行API + --- + summary: 投資条件に基づき、将来の資産推移と関連イベントを計算します + description: | + 指定された銘柄、期間、投資額に基づいてシミュレーションを行います。 + レスポンスの `graph_data` は、フロントエンドでチャートを描画するために + 日付と資産額がペアになった時系列データとして返されます。 + parameters: + - name: body + in: body + required: true + schema: + type: object + properties: + initial_investment: + type: number + description: 初期投資額 (円) + example: 1000000 + monthly_investment: + type: number + description: 毎月の積立額 (円) + example: 50000 + start_date: + type: string + format: date + description: 開始日 (YYYY-MM-DD) + example: "2015-01-01" + end_date: + type: string + format: date + description: 終了日 (YYYY-MM-DD) + example: "2024-12-31" + tickers: + type: array + description: 投資する銘柄コードのリスト + items: + type: string + example: ["7203.T", "AAPL"] + responses: + 200: + description: シミュレーション結果 + schema: + type: object + properties: + graph_data: + type: array + description: | + グラフ描画用の時系列データリスト。 + 日付(X軸)と、その日の資産評価額・元本(Y軸)が含まれます。 + items: + type: object + properties: + date: + type: string + description: 日付 (YYYY-MM-DD) + example: "2015-01-01" + total_assets: + type: number + description: その日の資産評価額合計 (円) + example: 1050000 + invested_amount: + type: number + description: その日までの投資元本合計 (円) + example: 1000000 + events: + type: array + description: 期間中に発生した関連ニュース・イベントのリスト + items: + type: object + properties: + date: + type: string + example: "2018-11-19" + ticker: + type: string + example: "7201.T" + title: + type: string + example: "カルロス・ゴーン会長逮捕" + description: + type: string + example: "金融商品取引法違反の疑いで..." + sentiment: + type: number + example: -0.9 + summary: + type: object + description: 最終結果のサマリー + properties: + final_assets: + type: number + description: 最終資産額 + total_invested: + type: number + description: 投資元本合計 + return_rate: + type: number + description: 損益率 (%) + """ + try: + data = request.json + result = simulation_logic.run_simulation_logic(data) + return jsonify(result) + except Exception as e: + print(e) + return jsonify({"error": str(e)}), 500 + +@app.route('/api/save_setting', methods=['POST']) +def save_setting(): + """ + 設定保存API + --- + description: 現在の入力設定を保存します(現在はログ出力のみ) + parameters: + - name: body + in: body + required: true + schema: + type: object + description: 保存する任意のJSONデータ + responses: + 200: + description: 保存成功 + """ + data = request.json + print(f" User Settings Saved: {data}") + return jsonify({"message": "Settings saved successfully"}) + +if __name__ == '__main__': + app.run(debug=True, port=5000) \ No newline at end of file diff --git a/flutter_newokanelab/lib/core/constants.dart b/flutter_newokanelab/lib/core/constants.dart index 88a852b1..41005b25 100644 --- a/flutter_newokanelab/lib/core/constants.dart +++ b/flutter_newokanelab/lib/core/constants.dart @@ -1 +1,6 @@ -//定数を書くファイル +import 'package:flutter/material.dart'; + +// App Colors +const Color kMainColor = Color(0xFFF7F6F5); +const Color kCardColor = Color(0xFFFFF9F5); +const Color kGreenColor = Color(0xFF79C06E); \ No newline at end of file diff --git a/flutter_newokanelab/lib/main.dart b/flutter_newokanelab/lib/main.dart index 7b7f5b6f..3e243dbb 100644 --- a/flutter_newokanelab/lib/main.dart +++ b/flutter_newokanelab/lib/main.dart @@ -1,122 +1,73 @@ import 'package:flutter/material.dart'; +import 'core/constants.dart'; +import 'screens/home_screen.dart'; // Import the new home screen +/// アプリケーションのエントリーポイント(開始点) void main() { - runApp(const MyApp()); + // InvestmentSimulatorApp ウィジェットをアプリケーションのルートとして実行 + runApp(const InvestmentSimulatorApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +/// アプリケーション全体のテーマと基本的な設定を定義するルートウィジェット +class InvestmentSimulatorApp extends StatelessWidget { + // constコンストラクタ + const InvestmentSimulatorApp({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { + // MaterialAppウィジェットは、アプリケーションの基本的な構造やナビゲーションを提供 return MaterialApp( - title: 'Flutter Demo', + // アプリケーションのタイトル + title: '投資シミュレーター', + // アプリケーション全体のテーマ設定 theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + brightness: Brightness.light, + primaryColor: kGreenColor, + scaffoldBackgroundColor: kMainColor, + cardColor: kCardColor, + colorScheme: ColorScheme.fromSeed( + seedColor: kGreenColor, + brightness: Brightness.light, + primary: kGreenColor, + background: kMainColor, + surface: kCardColor, + onSurface: Colors.black, + ), + textTheme: const TextTheme( + bodyMedium: TextStyle(color: Colors.black), + headlineSmall: TextStyle(fontWeight: FontWeight.bold), + titleLarge: TextStyle(fontWeight: FontWeight.bold, color: kGreenColor), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: BorderSide(color: Colors.grey.shade400), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: kGreenColor, width: 2), + ), + fillColor: kCardColor, + filled: true, + ), + sliderTheme: SliderThemeData( + activeTrackColor: kGreenColor, + inactiveTrackColor: Colors.grey[300], + thumbColor: kGreenColor, + overlayColor: kGreenColor.withAlpha(100), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: kGreenColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), ), ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. + // アプリケーションのホームページ + home: const HomePage(), ); } } diff --git a/flutter_newokanelab/lib/models/investment_models.dart b/flutter_newokanelab/lib/models/investment_models.dart new file mode 100644 index 00000000..b711be71 --- /dev/null +++ b/flutter_newokanelab/lib/models/investment_models.dart @@ -0,0 +1,28 @@ +import 'package:fl_chart/fl_chart.dart'; + +// --- データモデルクラス --- + +/// 投資ファンドのデータを保持するクラス +class Fund { + final String id; // ファンドの一意なID + final String label; // ファンドの表示名 + final double annualReturn; // 年間リターン(利率) + + const Fund({required this.id, required this.label, required this.annualReturn}); +} + +/// ライフイベントのデータを保持するクラス +class LifeEvent { + final String id; // イベントの一意なID + final String label; // イベントの表示名 + + const LifeEvent({required this.id, required this.label}); +} + +/// シミュレーション結果のデータを保持するクラス +class SimulationResult { + final List projectionData; // グラフ描画用のデータ + final Map summaryData; // サマリー表示用のデータ + + SimulationResult({required this.projectionData, required this.summaryData}); +} diff --git a/flutter_newokanelab/lib/screens/home_screen.dart b/flutter_newokanelab/lib/screens/home_screen.dart new file mode 100644 index 00000000..47614833 --- /dev/null +++ b/flutter_newokanelab/lib/screens/home_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import '../models/investment_models.dart'; +import '../services/simulation_service.dart'; +import '../widgets/input_section.dart'; +import '../widgets/results_section.dart'; + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _scrollController = ScrollController(); + final _resultsKey = GlobalKey(); + + // --- State Variables --- + double _initialInvestment = 300000; + double _simulationYears = 20; + bool _isMonthlyMode = false; + double _fixedMonthlyInvestment = 20000; + late List _monthlyInvestments; + String _selectedFundId = "balanced"; + final Set _selectedEventIds = {}; + + bool _isLoading = false; + SimulationResult? _simulationResult; + + // --- Static Data --- + final List _funds = const [ + Fund(id: "aggressive", label: "アグレッシブファンド", annualReturn: 0.08), + Fund(id: "balanced", label: "バランスファンド", annualReturn: 0.06), + Fund(id: "conservative", label: "コンサバティブファンド", annualReturn: 0.04), + Fund(id: "growth", label: "グロースファンド", annualReturn: 0.09), + Fund(id: "income", label: "インカムファンド", annualReturn: 0.05), + ]; + final List _events = const [ + LifeEvent(id: "retirement", label: "バブル崩壊"), + LifeEvent(id: "education", label: "コロナショック"), + LifeEvent(id: "housing", label: "トランプショック"), + ]; + final List _months = const [ + "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月" + ]; + + @override + void initState() { + super.initState(); + _monthlyInvestments = List.filled(12, 20000); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + double get _averageMonthlyInvestment { + if (!_isMonthlyMode) { + return _fixedMonthlyInvestment; + } + return _monthlyInvestments.reduce((a, b) => a + b) / 12; + } + + void _runSimulation() async { + setState(() { + _isLoading = true; + _simulationResult = null; + }); + + final result = await SimulationService.calculate( + initialInvestment: _initialInvestment, + averageMonthlyInvestment: _averageMonthlyInvestment, + selectedFundId: _selectedFundId, + funds: _funds, + simulationYears: _simulationYears.round(), + ); + + setState(() { + _simulationResult = result; + _isLoading = false; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = _resultsKey.currentContext; + if (context != null) { + Scrollable.ensureVisible(context, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut); + } + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('投資シミュレーター'), + backgroundColor: Theme.of(context).cardColor, + elevation: 1, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(20.0), + child: Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '将来の資産形成をシミュレーションします', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ), + ), + body: SingleChildScrollView( + controller: _scrollController, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InputSection( + initialInvestment: _initialInvestment, + onInitialInvestmentChanged: (value) => setState(() => _initialInvestment = value), + simulationYears: _simulationYears ?? 20, + onSimulationYearsChanged: (value) => setState(() => _simulationYears = value), + isMonthlyMode: _isMonthlyMode, + onMonthlyModeChanged: (value) => setState(() => _isMonthlyMode = value), + fixedMonthlyInvestment: _fixedMonthlyInvestment, + onFixedMonthlyInvestmentChanged: (value) => setState(() => _fixedMonthlyInvestment = value), + monthlyInvestments: _monthlyInvestments, + onMonthlyInvestmentChanged: (index, value) => setState(() => _monthlyInvestments[index] = value), + selectedFundId: _selectedFundId, + onFundIdChanged: (value) { + if (value != null) { + setState(() => _selectedFundId = value); + } + }, + selectedEventIds: _selectedEventIds, + onEventIdToggled: (id, value) { + setState(() { + if (value == true) { + _selectedEventIds.add(id); + } else { + _selectedEventIds.remove(id); + } + }); + }, + funds: _funds, + events: _events, + months: _months, + averageMonthlyInvestment: _averageMonthlyInvestment, + onSimulatePressed: _runSimulation, + ), + const SizedBox(height: 16), + ResultsSection( + isLoading: _isLoading, + simulationResult: _simulationResult, + resultsKey: _resultsKey, + initialInvestment: _initialInvestment, + averageMonthlyInvestment: _averageMonthlyInvestment, + simulationYears: _simulationYears ?? 20, + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_newokanelab/lib/services/simulation_service.dart b/flutter_newokanelab/lib/services/simulation_service.dart new file mode 100644 index 00000000..5c3b47af --- /dev/null +++ b/flutter_newokanelab/lib/services/simulation_service.dart @@ -0,0 +1,49 @@ +import 'package:fl_chart/fl_chart.dart'; +import '../models/investment_models.dart'; + +class SimulationService { + /// シミュレーションを実行し、結果を返す + static Future calculate({ + required double initialInvestment, + required double averageMonthlyInvestment, + required String selectedFundId, + required List funds, + required int simulationYears, + }) async { + // 実際のアプリではここでAPI通信を行う。 + // この例では、2秒待つことで非同期処理を模倣。 + await Future.delayed(const Duration(seconds: 2)); + + // 選択されたファンドの情報を取得 + final fund = funds.firstWhere((f) => f.id == selectedFundId); + final returnRate = fund.annualReturn; + + // グラフ描画用のデータを生成 + double projectionTotal = initialInvestment; + final List projectionData = []; + for (int year = 0; year <= simulationYears; year++) { + projectionData.add(FlSpot(year.toDouble(), projectionTotal.roundToDouble())); + projectionTotal = projectionTotal * (1 + returnRate) + averageMonthlyInvestment * 12; + } + + // サマリー表示用のデータを生成 + double summaryTotal = initialInvestment; + for (int i = 1; i <= simulationYears; i++) { + summaryTotal = summaryTotal * (1 + returnRate) + averageMonthlyInvestment * 12; + } + final totalInvested = initialInvestment + averageMonthlyInvestment * 12 * simulationYears; + final profit = summaryTotal - totalInvested; + final profitRate = (profit / totalInvested) * 100; + + // 計算結果をMapにまとめる + final summaryData = { + "totalAmount": summaryTotal, + "totalInvested": totalInvested, + "profit": profit, + "profitRate": profitRate, + }; + + // SimulationResultオブジェクトとして返す + return SimulationResult(projectionData: projectionData, summaryData: summaryData); + } +} diff --git a/flutter_newokanelab/lib/widgets/input_section.dart b/flutter_newokanelab/lib/widgets/input_section.dart new file mode 100644 index 00000000..4a8208ba --- /dev/null +++ b/flutter_newokanelab/lib/widgets/input_section.dart @@ -0,0 +1,423 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/investment_models.dart'; + +/// ユーザー入力を受け付けるフォームセクション全体を構築するStatelessWidget +class InputSection extends StatelessWidget { + // --- 現在の入力値 --- + final double initialInvestment; + final double? simulationYears; + final bool isMonthlyMode; + final double fixedMonthlyInvestment; + final List monthlyInvestments; + final String selectedFundId; + final Set selectedEventIds; + + // --- データ --- + final List funds; + final List events; + final List months; + final double averageMonthlyInvestment; + + // --- コールバック関数 --- + final ValueChanged onInitialInvestmentChanged; + final ValueChanged onSimulationYearsChanged; + final ValueChanged onMonthlyModeChanged; + final ValueChanged onFixedMonthlyInvestmentChanged; + final void Function(int, double) onMonthlyInvestmentChanged; + final ValueChanged onFundIdChanged; + final void Function(String, bool?) onEventIdToggled; + final VoidCallback onSimulatePressed; + + const InputSection({ + super.key, + required this.initialInvestment, + this.simulationYears, + required this.isMonthlyMode, + required this.fixedMonthlyInvestment, + required this.monthlyInvestments, + required this.selectedFundId, + required this.selectedEventIds, + required this.funds, + required this.events, + required this.months, + required this.averageMonthlyInvestment, + required this.onInitialInvestmentChanged, + required this.onSimulationYearsChanged, + required this.onMonthlyModeChanged, + required this.onFixedMonthlyInvestmentChanged, + required this.onMonthlyInvestmentChanged, + required this.onFundIdChanged, + required this.onEventIdToggled, + required this.onSimulatePressed, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + _buildInitialInvestmentCard(context), + const SizedBox(height: 16), + _buildYearsSelectorCard(context), + ], + ), + ), + const SizedBox(width: 16), + Expanded(child: _buildFundSelectorCard(context)), + ], + ), + const SizedBox(height: 16), + _buildMonthlyInvestmentCard(context), + const SizedBox(height: 16), + _buildEventsCard(context), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onSimulatePressed, + child: const Text('シミュレーションを実行'), + ), + ], + ); + } + + /// 初期投資金額設定カード + Widget _buildInitialInvestmentCard(BuildContext context) { + final formatter = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("初期投資金額", style: Theme.of(context).textTheme.bodySmall), + InkWell( + onTap: () { + _showNumberInputDialog( + context: context, + title: '初期投資金額', + initialValue: initialInvestment.toStringAsFixed(0), + onSave: (value) { + final newValue = double.tryParse(value); + if (newValue != null) { + final clampedValue = newValue.clamp(10000.0, 1000000.0); + onInitialInvestmentChanged(clampedValue); + } + }, + ); + }, + child: Text( + formatter.format(initialInvestment), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Theme.of(context).colorScheme.tertiary, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Slider( + value: initialInvestment, + min: 10000, + max: 1000000, + divisions: 99, + onChanged: onInitialInvestmentChanged, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("¥10,000", style: Theme.of(context).textTheme.bodySmall), + Text("¥1,000,000", style: Theme.of(context).textTheme.bodySmall), + ], + ), + ], + ), + ), + ); + } + + /// シミュレーション年数設定カード + Widget _buildYearsSelectorCard(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("シミュレーション年数", style: Theme.of(context).textTheme.bodySmall), + InkWell( + onTap: () { + _showNumberInputDialog( + context: context, + title: 'シミュレーション年数', + initialValue: (simulationYears ?? 20).round().toString(), + onSave: (value) { + final newValue = double.tryParse(value); + if (newValue != null) { + final clampedValue = newValue.clamp(1.0, 50.0); + onSimulationYearsChanged(clampedValue); + } + }, + ); + }, + child: Text( + '${(simulationYears ?? 20).round()} 年', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Theme.of(context).colorScheme.tertiary, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Slider( + value: simulationYears ?? 20, + min: 1, + max: 50, + divisions: 49, + onChanged: onSimulationYearsChanged, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("1年", style: Theme.of(context).textTheme.bodySmall), + Text("50年", style: Theme.of(context).textTheme.bodySmall), + ], + ), + ], + ), + ), + ); + } + + /// ファンド選択カード + Widget _buildFundSelectorCard(BuildContext context) { + final selectedFund = funds.firstWhere((f) => f.id == selectedFundId); + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("ファンドを選択", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + DropdownButtonFormField( + value: selectedFundId, + items: funds.map((fund) { + return DropdownMenuItem( + value: fund.id, + child: Text('${fund.label} (リターン: ${(fund.annualReturn * 100).toStringAsFixed(1)}%)'), + ); + }).toList(), + onChanged: onFundIdChanged, + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Text("選択中のファンド: "), + Text(selectedFund.label, style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.tertiary)), + ], + ), + ), + ], + ), + ), + ); + } + + /// 月額投資設定カード + Widget _buildMonthlyInvestmentCard(BuildContext context) { + final formatter = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("月ごとの投資金額設定", style: TextStyle(fontWeight: FontWeight.bold)), + Row( + children: [ + Text("定額", style: TextStyle(color: !isMonthlyMode ? Theme.of(context).colorScheme.tertiary : Colors.grey)), + Switch( + value: isMonthlyMode, + onChanged: onMonthlyModeChanged, + activeColor: Theme.of(context).colorScheme.tertiary, + ), + Text("月ごと", style: TextStyle(color: isMonthlyMode ? Theme.of(context).colorScheme.tertiary : Colors.grey)), + ], + ), + ], + ), + const SizedBox(height: 16), + !isMonthlyMode + ? _buildFixedInvestmentInput(context) + : _buildMonthlyInvestmentInputs(context), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Text( + isMonthlyMode + ? '平均月額: ${formatter.format(averageMonthlyInvestment)}' + : '年間投資額: ${formatter.format(fixedMonthlyInvestment * 12)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ); + } + + /// 定額投資入力フィールド + Widget _buildFixedInvestmentInput(BuildContext context) { + return TextFormField( + initialValue: fixedMonthlyInvestment.toStringAsFixed(0), + decoration: const InputDecoration( + labelText: '月額投資金額', + prefixText: '¥ ', + ), + keyboardType: TextInputType.number, + onChanged: (value) => onFixedMonthlyInvestmentChanged(double.tryParse(value) ?? fixedMonthlyInvestment), + ); + } + + /// 月ごと投資入力フィールド + Widget _buildMonthlyInvestmentInputs(BuildContext context) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 3.0, + crossAxisSpacing: 10, + mainAxisSpacing: 5, + ), + itemCount: 12, + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(months[index], style: Theme.of(context).textTheme.bodySmall), + Expanded( + child: TextFormField( + initialValue: monthlyInvestments[index].toStringAsFixed(0), + keyboardType: TextInputType.number, + decoration: const InputDecoration( + prefixText: '¥ ', + contentPadding: EdgeInsets.symmetric(horizontal: 8), + ), + onChanged: (value) => onMonthlyInvestmentChanged(index, double.tryParse(value) ?? monthlyInvestments[index]), + ), + ), + ], + ); + }, + ); + } + + /// ライフイベント選択カード + Widget _buildEventsCard(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("重要なイベント", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + return CheckboxListTile( + title: Text(event.label, style: Theme.of(context).textTheme.bodySmall), + value: selectedEventIds.contains(event.id), + onChanged: (value) => onEventIdToggled(event.id, value), + controlAffinity: ListTileControlAffinity.leading, + activeColor: Theme.of(context).colorScheme.tertiary, + contentPadding: EdgeInsets.zero, + ); + }, + ), + ], + ), + ), + ); + } + + // Helper method to show a dialog for number input + Future _showNumberInputDialog({ + required BuildContext context, + required String title, + required String initialValue, + required void Function(String) onSave, + }) async { + final controller = TextEditingController(text: initialValue); + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + decoration: const InputDecoration( + hintText: '数値を入力してください', + ), + ), + actions: [ + TextButton( + child: const Text('キャンセル'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + child: const Text('保存'), + onPressed: () { + onSave(controller.text); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/flutter_newokanelab/lib/widgets/results_section.dart b/flutter_newokanelab/lib/widgets/results_section.dart new file mode 100644 index 00000000..c8cd5b7c --- /dev/null +++ b/flutter_newokanelab/lib/widgets/results_section.dart @@ -0,0 +1,286 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../models/investment_models.dart'; + +/// シミュレーション結果の表示セクション全体を構築するStatelessWidget +class ResultsSection extends StatelessWidget { + final bool isLoading; + final SimulationResult? simulationResult; + final GlobalKey resultsKey; + final double initialInvestment; + final double averageMonthlyInvestment; + final double? simulationYears; + + const ResultsSection({ + super.key, + required this.isLoading, + this.simulationResult, + required this.resultsKey, + required this.initialInvestment, + required this.averageMonthlyInvestment, + this.simulationYears, + }); + + @override + Widget build(BuildContext context) { + // 数値を通貨形式(円)にフォーマットするためのフォーマッター + final formatter = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + Widget content; + + if (isLoading) { + // ローディング中の場合、インジケーターを表示 + content = const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 80.0), + child: CircularProgressIndicator(), + )); + } else if (simulationResult == null) { + // まだ結果がない場合、初期メッセージを表示 + content = Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 80.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.insights, size: 80, color: Colors.grey[700]), + const SizedBox(height: 20), + Text( + 'シミュレーションを実行して\n結果を表示します', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[600], fontSize: 16), + ), + ], + ), + ), + ); + } else { + // 結果がある場合、グラフとサマリーを表示 + final projectionData = simulationResult!.projectionData; + final summaryData = simulationResult!.summaryData; + // LayoutBuilderを使って、画面幅に応じたレイアウト切り替えを行う + content = LayoutBuilder( + builder: (context, constraints) { + // 画面幅が800pxより大きい場合 (PCレイアウト) + if (constraints.maxWidth > 800) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildChartCard(context, projectionData), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildInvestmentSummaryCard(context, summaryData, formatter)), + const SizedBox(width: 16), + Expanded(child: _buildExpectedResultsCard(context, summaryData, formatter)), + ], + ), + ], + ); + } else { // 画面幅が800px以下の場合 (モバイルレイアウト) + return Column( + children: [ + _buildChartCard(context, projectionData), + const SizedBox(height: 16), + _buildSummaryCards(context, summaryData, formatter), + ], + ); + } + }, + ); + } + + // 結果セクション全体のウィジェット + return Column( + key: resultsKey, // スクロール位置の特定に使用 + children: [ + Padding( + padding: const EdgeInsets.only(top: 24, bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("シミュレーション結果", style: Theme.of(context).textTheme.headlineSmall), + ], + ), + ), + const SizedBox(height: 8), + content, + ], + ); + } + + /// 資産成長グラフのカードを構築 + Widget _buildChartCard(BuildContext context, List data) { + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("${(simulationYears ?? 20).round()}年間の資産成長", style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 24), + SizedBox( + height: 500, + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: true, + getDrawingHorizontalLine: (value) => FlLine(color: Colors.white.withOpacity(0.1), strokeWidth: 1), + getDrawingVerticalLine: (value) => FlLine(color: Colors.white.withOpacity(0.1), strokeWidth: 1), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 60, getTitlesWidget: _leftTitleWidgets)), + bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 30, getTitlesWidget: _bottomTitleWidgets)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: true, border: Border.all(color: Colors.white.withOpacity(0.1))), + lineBarsData: [ + LineChartBarData( + spots: data, + isCurved: true, + color: Theme.of(context).colorScheme.tertiary, + barWidth: 4, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: Theme.of(context).colorScheme.tertiary.withOpacity(0.3), + ), + ), + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (touchedBarSpots) { + return touchedBarSpots.map((barSpot) { + final year = barSpot.x.toInt(); + final amount = barSpot.y; + final formatter = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + return LineTooltipItem( + '${year}年目\n${formatter.format(amount)}', + TextStyle(color: Theme.of(context).colorScheme.onPrimary), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + /// グラフのX軸(下側)のラベルを構築 + Widget _bottomTitleWidgets(double value, TitleMeta meta) { + const style = TextStyle(fontSize: 10); + Widget text; + if (value.toInt() % 5 == 0) { + text = Text('${value.toInt()}年', style: style); + } else { + text = const Text('', style: style); + } + return SideTitleWidget(axisSide: meta.axisSide, space: 8.0, child: text); + } + + /// グラフのY軸(左側)のラベルを構築 + Widget _leftTitleWidgets(double value, TitleMeta meta) { + final style = TextStyle(fontSize: 10, color: Colors.grey[400]); + if (value % 1000000 == 0 && value != 0) { + return Text('${(value / 1000000).toInt()}M', style: style, textAlign: TextAlign.right); + } + return const SizedBox.shrink(); + } + + /// サマリーカード群を構築(モバイルレイアウト用) + Widget _buildSummaryCards(BuildContext context, Map summaryData, NumberFormat formatter) { + if (summaryData['totalInvested'] == null || summaryData['totalAmount'] == null || summaryData['profit'] == null || summaryData['profitRate'] == null) { + return const SizedBox.shrink(); + } + return Column( + children: [ + _buildInvestmentSummaryCard(context, summaryData, formatter), + const SizedBox(height: 16), + _buildExpectedResultsCard(context, summaryData, formatter), + ], + ); + } + + /// 投資額サマリーカードを構築 + Widget _buildInvestmentSummaryCard(BuildContext context, Map summary, NumberFormat formatter) { + final years = (simulationYears ?? 20).round(); + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("投資額サマリー", style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 12), + _buildSummaryRow(context, "初期投資", formatter.format(initialInvestment)), + _buildSummaryRow(context, "月額投資 × ${years * 12}ヶ月", formatter.format(averageMonthlyInvestment * 12 * years)), + const Divider(height: 24), + _buildSummaryRow(context, "総投資額", formatter.format(summary['totalInvested']), isTotal: true), + ], + ), + ), + ); + } + + /// 予想成果カードを構築 + Widget _buildExpectedResultsCard(BuildContext context, Map summary, NumberFormat formatter) { + final years = (simulationYears ?? 20).round(); + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("予想成果(${years}年後)", style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 12), + Text("予想資産額", style: Theme.of(context).textTheme.bodySmall), + Text( + formatter.format(summary['totalAmount']), + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.tertiary, + ), + ), + const Divider(height: 24), + Text("利益", style: Theme.of(context).textTheme.bodySmall), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatter.format(summary['profit']), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + Text('+${summary['profitRate']?.toStringAsFixed(1)}%', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Theme.of(context).colorScheme.tertiary)), + ], + ) + ], + ), + ), + ); + } + + /// サマリーカード内の各行を構築 + Widget _buildSummaryRow(BuildContext context, String label, String value, {bool isTotal = false}) { + final valueStyle = isTotal + ? TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.tertiary, fontSize: 16) + : const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + Text(value, style: valueStyle), + ], + ), + ); + } +} diff --git a/flutter_newokanelab/pubspec.lock b/flutter_newokanelab/pubspec.lock index d074b124..e8fb9f0c 100644 --- a/flutter_newokanelab/pubspec.lock +++ b/flutter_newokanelab/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -57,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: d0f0d49112f2f4b192481c16d05b6418bd7820e021e265a3c22db98acf7ed7fb + url: "https://pub.dev" + source: hosted + version: "0.68.0" flutter: dependency: "direct main" description: flutter @@ -75,6 +91,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: diff --git a/flutter_newokanelab/pubspec.yaml b/flutter_newokanelab/pubspec.yaml index db66e012..713659a0 100644 --- a/flutter_newokanelab/pubspec.yaml +++ b/flutter_newokanelab/pubspec.yaml @@ -1,78 +1,40 @@ -name: flutter_newokanelab -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +name: flutter_newokanelab # パッケージ名 +description: "A new Flutter project." # パッケージの説明 +publish_to: 'none' # pub.devへの公開を防ぐ -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0+1 # アプリケーションのバージョン番号 environment: - sdk: ^3.9.0 + sdk: '>=3.9.0 <4.0.0' # Dart SDKのバージョン制約 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter + fl_chart: ^0.68.0 # グラフ描画ライブラリ + intl: ^0.19.0 # 国際化ライブラリ - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + # iOSスタイルのアイコンフォント cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. + # コーディング規約をチェックするための推奨Linter flutter_lints: ^5.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. +# Flutter固有の設定 flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. + # Material Iconsフォントを有効にする uses-material-design: true - # To add assets to your application, add an assets section, like this: + # アセット(画像など)を追加するには、次のように記述します # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: + # カスタムフォントを追加するには、次のように記述します # fonts: # - family: Schyler # fonts: @@ -84,6 +46,3 @@ flutter: # - asset: fonts/TrajanPro.ttf # - asset: fonts/TrajanPro_Bold.ttf # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package From 5e70de81a058e9f758339e0c5c1802d04a8478cc Mon Sep 17 00:00:00 2001 From: Mol_ Date: Thu, 11 Dec 2025 09:34:00 +0900 Subject: [PATCH 2/3] ?? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 些細でささいな修正(自分も何してるんか忘れた) --- .../lib/screens/home_screen.dart | 4 ++-- .../lib/services/api_service.dart | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 flutter_newokanelab/lib/services/api_service.dart diff --git a/flutter_newokanelab/lib/screens/home_screen.dart b/flutter_newokanelab/lib/screens/home_screen.dart index 47614833..6691941f 100644 --- a/flutter_newokanelab/lib/screens/home_screen.dart +++ b/flutter_newokanelab/lib/screens/home_screen.dart @@ -124,7 +124,7 @@ class _HomePageState extends State { InputSection( initialInvestment: _initialInvestment, onInitialInvestmentChanged: (value) => setState(() => _initialInvestment = value), - simulationYears: _simulationYears ?? 20, + simulationYears: _simulationYears, onSimulationYearsChanged: (value) => setState(() => _simulationYears = value), isMonthlyMode: _isMonthlyMode, onMonthlyModeChanged: (value) => setState(() => _isMonthlyMode = value), @@ -161,7 +161,7 @@ class _HomePageState extends State { resultsKey: _resultsKey, initialInvestment: _initialInvestment, averageMonthlyInvestment: _averageMonthlyInvestment, - simulationYears: _simulationYears ?? 20, + simulationYears: _simulationYears, ), ], ), diff --git a/flutter_newokanelab/lib/services/api_service.dart b/flutter_newokanelab/lib/services/api_service.dart new file mode 100644 index 00000000..8eb88a55 --- /dev/null +++ b/flutter_newokanelab/lib/services/api_service.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter_newokanelab/models/investment_models.dart'; + +class ApiService { + final String _baseUrl = "http://127.0.0.1:5000"; // Pythonサーバーのアドレス + + // 会社一覧を取得する + Future> getCompanies() async { + final response = await http.get(Uri.parse('$_baseUrl/companies')); + + if (response.statusCode == 200) { + // 日本語が含まれる可能性があるため、UTF-8でデコード + final List jsonResponse = json.decode(utf8.decode(response.bodyBytes)); + return jsonResponse.map((company) => Company.fromJson(company)).toList(); + } else { + throw Exception('Failed to load companies'); + } + } +} From d8772a320002c16395cb38a39bb68f387f66a630 Mon Sep 17 00:00:00 2001 From: Mol_ Date: Tue, 13 Jan 2026 13:34:43 +0900 Subject: [PATCH 3/3] var,.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flutter_newokanelab/lib/core/api_config.dart ファイル内の接続先のURLを正しいURLに書き換えてください。 --- .vscode/settings.json | 3 + backendInfo/simulation_logic.py | 155 -------------- ...343\202\271\346\203\205\345\240\261app.py" | 195 ------------------ flutter_newokanelab/lib/core/api_config.dart | 26 +++ .../lib/models/investment_models.dart | 175 ++++++++++++++-- .../lib/screens/home_screen.dart | 23 ++- .../lib/services/api_service.dart | 59 +++++- .../lib/services/simulation_service.dart | 91 +++++--- flutter_newokanelab/pubspec.lock | 32 +++ flutter_newokanelab/pubspec.yaml | 1 + 10 files changed, 348 insertions(+), 412 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 backendInfo/simulation_logic.py delete mode 100644 "backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" create mode 100644 flutter_newokanelab/lib/core/api_config.dart diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9ddf6b28 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cmake.ignoreCMakeListsMissing": true +} \ No newline at end of file diff --git a/backendInfo/simulation_logic.py b/backendInfo/simulation_logic.py deleted file mode 100644 index 3c0cd0b1..00000000 --- a/backendInfo/simulation_logic.py +++ /dev/null @@ -1,155 +0,0 @@ -import pandas as pd -from sqlalchemy import create_engine, text -import urllib.parse - -# DB設定 -MYSQL_USER = 'root' -MYSQL_PASSWORD = 'root' -MYSQL_HOST = 'localhost' -MYSQL_DB = 'trade_sim' - -def get_db_engine(): - encoded_password = urllib.parse.quote_plus(MYSQL_PASSWORD) - conn_str = f"mysql+pymysql://{MYSQL_USER}:{encoded_password}@{MYSQL_HOST}/{MYSQL_DB}?charset=utf8mb4" - return create_engine(conn_str) - -def get_companies_list(): - """ フロントのプルダウン用に企業リストを返す """ - engine = get_db_engine() - query = "SELECT ticker_code, company_name FROM companies" - df = pd.read_sql(query, engine) - return df.to_dict(orient='records') - -def run_simulation_logic(data): - """ - シミュレーション実行のコアロジック - data = { - "initial_investment": 1000000, - "monthly_investment": 50000, - "tickers": ["7203.T", "AAPL"], - "start_date": "2015-01-01", - "end_date": "2024-12-31" - } - """ - engine = get_db_engine() - - tickers = data.get('tickers', []) - start_date = data.get('start_date', '2015-01-01') - end_date = data.get('end_date', '2025-12-31') - initial_inv = float(data.get('initial_investment', 0)) - monthly_inv = float(data.get('monthly_investment', 0)) - - if not tickers: - return {"error": "No tickers selected"} - - # ------------------------------------------------- - # 1. 株価データの取得 - # ------------------------------------------------- - # 選択された銘柄の期間中のデータを取得 - tickers_str = "', '".join(tickers) - query_stock = f""" - SELECT date, ticker_code, close - FROM factstock_daily - WHERE ticker_code IN ('{tickers_str}') - AND date BETWEEN '{start_date}' AND '{end_date}' - ORDER BY date ASC - """ - df = pd.read_sql(query_stock, engine) - - # 日付をDatetime型に変換 - df['date'] = pd.to_datetime(df['date']) - - # ピボットテーブル化(行:日付, 列:銘柄, 値:終値) - df_pivot = df.pivot(index='date', columns='ticker_code', values='close') - - # 欠損値は前日の値で埋める(土日祝対策) - df_pivot = df_pivot.fillna(method='ffill') - # それでも無い(上場前など)は0にするか除外するが、今回はそのまま - - # ------------------------------------------------- - # 2. シミュレーション計算 (簡易版: 等分投資) - # ------------------------------------------------- - # 毎月の積立額を銘柄数で割る - inv_per_ticker = monthly_inv / len(tickers) - initial_per_ticker = initial_inv / len(tickers) - - # 保有株数管理 - holdings = {t: 0.0 for t in tickers} - - # 初期投資 (開始日時点の株価で購入) - if not df_pivot.empty: - first_date = df_pivot.index[0] - for t in tickers: - if t in df_pivot.columns and pd.notna(df_pivot.loc[first_date, t]): - price = df_pivot.loc[first_date, t] - if price > 0: - holdings[t] += initial_per_ticker / price - - # 日次ループ用の結果リスト - daily_result = [] - - # 月次積立の判定用 (YYYY-MMが変わったら投資) - last_month = None - - total_invested = initial_inv - - for date, row in df_pivot.iterrows(): - current_month = date.strftime('%Y-%m') - - # 月が変わったら積立投資 (毎月1回) - if last_month and current_month != last_month: - total_invested += monthly_inv - for t in tickers: - if t in row and pd.notna(row[t]) and row[t] > 0: - holdings[t] += inv_per_ticker / row[t] - - last_month = current_month - - # 現在の資産評価額計算 - current_total_value = 0 - breakdown = {} - - for t in tickers: - if t in row and pd.notna(row[t]): - val = holdings[t] * row[t] - current_total_value += val - breakdown[t] = val - - daily_result.append({ - "date": date.strftime('%Y-%m-%d'), - "total_assets": round(current_total_value), - "invested_amount": round(total_invested), - # "breakdown": breakdown # 内訳が必要ならコメントアウト解除 - }) - - # ------------------------------------------------- - # 3. 関連イベントの取得 - # ------------------------------------------------- - # チャートに表示するため、その期間の選択銘柄 + 市場全体のイベントを取得 - query_events = f""" - SELECT event_date, ticker_code, title, description, sentiment_score - FROM fact_events - WHERE (ticker_code IN ('{tickers_str}') OR ticker_code IS NULL) - AND event_date BETWEEN '{start_date}' AND '{end_date}' - ORDER BY event_date ASC - """ - df_events = pd.read_sql(query_events, engine) - events_list = [] - for _, row in df_events.iterrows(): - events_list.append({ - "date": row['event_date'].strftime('%Y-%m-%d'), - "ticker": row['ticker_code'] if row['ticker_code'] else "Market", - "title": row['title'], - "description": row['description'], - "sentiment": row['sentiment_score'] - }) - - return { - "graph_data": daily_result, - "events": events_list, - "summary": { - "final_assets": daily_result[-1]["total_assets"] if daily_result else 0, - "total_invested": daily_result[-1]["invested_amount"] if daily_result else 0, - "return_rate": round((daily_result[-1]["total_assets"] / daily_result[-1]["invested_amount"] * 100) - 100, 2) if daily_result and daily_result[-1]["invested_amount"] > 0 else 0 - } - } \ No newline at end of file diff --git "a/backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" "b/backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" deleted file mode 100644 index d93bf55f..00000000 --- "a/backendInfo/\343\203\207\343\203\274\343\202\277\343\203\231\343\203\274\343\202\271\346\203\205\345\240\261app.py" +++ /dev/null @@ -1,195 +0,0 @@ -from flask import Flask, request, jsonify -from flask_cors import CORS -from flasgger import Swagger -import backendInfo.simulation_logic as simulation_logic - -app = Flask(__name__) -CORS(app) - -# Swaggerの設定 -app.config['SWAGGER'] = { - 'title': 'Trade Sim API', - 'uiversion': 3 -} -swagger = Swagger(app) - -@app.route('/api/health', methods=['GET']) -def health_check(): - """ - ヘルスチェック用API - --- - responses: - 200: - description: サーバー稼働状況 - schema: - type: object - properties: - status: - type: string - example: ok - message: - type: string - example: Backend is running! - """ - return jsonify({"status": "ok", "message": "Backend is running!"}) - -@app.route('/api/companies', methods=['GET']) -def get_companies(): - """ - 企業リスト取得API - --- - description: データベースに登録されている全企業のリストを返します - responses: - 200: - description: 成功 - schema: - type: array - items: - type: object - properties: - ticker_code: - type: string - example: 7203.T - company_name: - type: string - example: トヨタ自動車 - """ - try: - companies = simulation_logic.get_companies_list() - return jsonify(companies) - except Exception as e: - return jsonify({"error": str(e)}), 500 - -@app.route('/api/simulate', methods=['POST']) -def simulate(): - """ - シミュレーション実行API - --- - summary: 投資条件に基づき、将来の資産推移と関連イベントを計算します - description: | - 指定された銘柄、期間、投資額に基づいてシミュレーションを行います。 - レスポンスの `graph_data` は、フロントエンドでチャートを描画するために - 日付と資産額がペアになった時系列データとして返されます。 - parameters: - - name: body - in: body - required: true - schema: - type: object - properties: - initial_investment: - type: number - description: 初期投資額 (円) - example: 1000000 - monthly_investment: - type: number - description: 毎月の積立額 (円) - example: 50000 - start_date: - type: string - format: date - description: 開始日 (YYYY-MM-DD) - example: "2015-01-01" - end_date: - type: string - format: date - description: 終了日 (YYYY-MM-DD) - example: "2024-12-31" - tickers: - type: array - description: 投資する銘柄コードのリスト - items: - type: string - example: ["7203.T", "AAPL"] - responses: - 200: - description: シミュレーション結果 - schema: - type: object - properties: - graph_data: - type: array - description: | - グラフ描画用の時系列データリスト。 - 日付(X軸)と、その日の資産評価額・元本(Y軸)が含まれます。 - items: - type: object - properties: - date: - type: string - description: 日付 (YYYY-MM-DD) - example: "2015-01-01" - total_assets: - type: number - description: その日の資産評価額合計 (円) - example: 1050000 - invested_amount: - type: number - description: その日までの投資元本合計 (円) - example: 1000000 - events: - type: array - description: 期間中に発生した関連ニュース・イベントのリスト - items: - type: object - properties: - date: - type: string - example: "2018-11-19" - ticker: - type: string - example: "7201.T" - title: - type: string - example: "カルロス・ゴーン会長逮捕" - description: - type: string - example: "金融商品取引法違反の疑いで..." - sentiment: - type: number - example: -0.9 - summary: - type: object - description: 最終結果のサマリー - properties: - final_assets: - type: number - description: 最終資産額 - total_invested: - type: number - description: 投資元本合計 - return_rate: - type: number - description: 損益率 (%) - """ - try: - data = request.json - result = simulation_logic.run_simulation_logic(data) - return jsonify(result) - except Exception as e: - print(e) - return jsonify({"error": str(e)}), 500 - -@app.route('/api/save_setting', methods=['POST']) -def save_setting(): - """ - 設定保存API - --- - description: 現在の入力設定を保存します(現在はログ出力のみ) - parameters: - - name: body - in: body - required: true - schema: - type: object - description: 保存する任意のJSONデータ - responses: - 200: - description: 保存成功 - """ - data = request.json - print(f" User Settings Saved: {data}") - return jsonify({"message": "Settings saved successfully"}) - -if __name__ == '__main__': - app.run(debug=True, port=5000) \ No newline at end of file diff --git a/flutter_newokanelab/lib/core/api_config.dart b/flutter_newokanelab/lib/core/api_config.dart new file mode 100644 index 00000000..ad076a71 --- /dev/null +++ b/flutter_newokanelab/lib/core/api_config.dart @@ -0,0 +1,26 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; + +class ApiConfig { + // Androidエミュレータからは '10.0.2.2'、iOSシミュレータやWebからは 'localhost' または '127.0.0.1' + // 実機の場合はPCのローカルIPアドレスを指定する必要があります + static String get baseUrl { + if (kReleaseMode) { + // 本番環境のURL(今回は未使用) + return 'http://your-production-server.com'; + } + + // Androidエミュレータの場合 + if (!kIsWeb && Platform.isAndroid) { + return 'http://10.0.2.2:8080'; + } + + // iOSシミュレータ、Web、デスクトップの場合 + return 'http://127.0.0.1:8080'; + } + + // エンドポイント + static String get healthEndpoint => '$baseUrl/api/health'; + static String get companiesEndpoint => '$baseUrl/api/companies'; + static String get simulateEndpoint => '$baseUrl/api/simulate'; +} \ No newline at end of file diff --git a/flutter_newokanelab/lib/models/investment_models.dart b/flutter_newokanelab/lib/models/investment_models.dart index b711be71..1a1f71b1 100644 --- a/flutter_newokanelab/lib/models/investment_models.dart +++ b/flutter_newokanelab/lib/models/investment_models.dart @@ -1,28 +1,179 @@ import 'package:fl_chart/fl_chart.dart'; -// --- データモデルクラス --- +// --- UI用モデル --- -/// 投資ファンドのデータを保持するクラス +/// 投資ファンドのデータ(UIでの選択肢) class Fund { - final String id; // ファンドの一意なID - final String label; // ファンドの表示名 - final double annualReturn; // 年間リターン(利率) + final String id; // ファンドID + final String label; // 表示名 + final double annualReturn; // (バックエンド利用時は参考値、またはロジック側で使用) const Fund({required this.id, required this.label, required this.annualReturn}); } -/// ライフイベントのデータを保持するクラス +/// ライフイベントのデータ(UIでの選択肢) class LifeEvent { - final String id; // イベントの一意なID - final String label; // イベントの表示名 + final String id; + final String label; const LifeEvent({required this.id, required this.label}); } -/// シミュレーション結果のデータを保持するクラス +// --- API連携用データモデル --- + +/// 企業情報 (/api/companies レスポンス用) +class Company { + final String tickerCode; + final String companyName; + + Company({required this.tickerCode, required this.companyName}); + + factory Company.fromJson(Map json) { + return Company( + tickerCode: json['ticker_code'] as String, + companyName: json['company_name'] as String, + ); + } +} + +/// シミュレーションAPIのレスポンス全体 +class SimulationResponse { + final List graphData; + final List events; + final SimulationSummary summary; + + SimulationResponse({ + required this.graphData, + required this.events, + required this.summary, + }); + + factory SimulationResponse.fromJson(Map json) { + return SimulationResponse( + graphData: (json['graph_data'] as List) + .map((e) => GraphData.fromJson(e)) + .toList(), + events: (json['events'] as List) + .map((e) => SimulationEvent.fromJson(e)) + .toList(), + summary: SimulationSummary.fromJson(json['summary']), + ); + } + + /// グラフ描画用に fl_chart の FlSpot リストに変換するヘルパーメソッド + /// X軸: シミュレーション開始からの経過年数 (0, 1, 2...) ※簡易的な変換 + /// 日付ベースでX軸を描画する場合は別途調整が必要ですが、 + /// 現在のUIロジックに合わせて「年単位」または「インデックス」でマッピングします。 + List toFlSpots() { + // データが多すぎる場合は間引くなどの処理が必要かもしれませんが、 + // ここでは単純に最初の日付からの経過年数(概算)をX軸にします。 + if (graphData.isEmpty) return []; + + final startDate = DateTime.parse(graphData.first.date); + return graphData.map((data) { + final currentDate = DateTime.parse(data.date); + final diffDays = currentDate.difference(startDate).inDays; + final years = diffDays / 365.25; // 年換算 + return FlSpot(years, data.totalAssets.toDouble()); + }).toList(); + } +} + +/// 日次の資産データ +class GraphData { + final String date; + final num totalAssets; + final num investedAmount; + + GraphData({ + required this.date, + required this.totalAssets, + required this.investedAmount, + }); + + factory GraphData.fromJson(Map json) { + return GraphData( + date: json['date'] as String, + totalAssets: json['total_assets'] as num, + investedAmount: json['invested_amount'] as num, + ); + } +} + +/// イベント(ニュース)データ +class SimulationEvent { + final String date; + final String ticker; + final String title; + final String description; + final num sentiment; + + SimulationEvent({ + required this.date, + required this.ticker, + required this.title, + required this.description, + required this.sentiment, + }); + + factory SimulationEvent.fromJson(Map json) { + return SimulationEvent( + date: json['date'] as String, + ticker: json['ticker'] as String, + title: json['title'] as String, + description: json['description'] as String, + sentiment: json['sentiment'] as num, + ); + } +} + +/// サマリーデータ +class SimulationSummary { + final num finalAssets; + final num totalInvested; + final num returnRate; + + SimulationSummary({ + required this.finalAssets, + required this.totalInvested, + required this.returnRate, + }); + + factory SimulationSummary.fromJson(Map json) { + return SimulationSummary( + finalAssets: json['final_assets'] as num, + totalInvested: json['total_invested'] as num, + returnRate: json['return_rate'] as num, + ); + } +} + +// --- アプリ内で使用する統合結果オブジェクト (ResultsSectionへ渡すもの) --- class SimulationResult { - final List projectionData; // グラフ描画用のデータ - final Map summaryData; // サマリー表示用のデータ + final List projectionData; + final Map summaryData; + final List events; // イベント表示用に追加 + + SimulationResult({ + required this.projectionData, + required this.summaryData, + this.events = const [], + }); - SimulationResult({required this.projectionData, required this.summaryData}); + /// APIレスポンスからUI用Resultを作成するファクトリ + factory SimulationResult.fromResponse(SimulationResponse response) { + final spots = response.toFlSpots(); + final profit = response.summary.finalAssets - response.summary.totalInvested; + + return SimulationResult( + projectionData: spots, + summaryData: { + "totalAmount": response.summary.finalAssets.toDouble(), + "totalInvested": response.summary.totalInvested.toDouble(), + "profit": profit.toDouble(), + "profitRate": response.summary.returnRate.toDouble(), + }, + events: response.events, + ); + } } diff --git a/flutter_newokanelab/lib/screens/home_screen.dart b/flutter_newokanelab/lib/screens/home_screen.dart index 6691941f..fe52ab9b 100644 --- a/flutter_newokanelab/lib/screens/home_screen.dart +++ b/flutter_newokanelab/lib/screens/home_screen.dart @@ -22,7 +22,7 @@ class _HomePageState extends State { bool _isMonthlyMode = false; double _fixedMonthlyInvestment = 20000; late List _monthlyInvestments; - String _selectedFundId = "balanced"; + String _selectedFundId = "7203.T"; final Set _selectedEventIds = {}; bool _isLoading = false; @@ -30,16 +30,21 @@ class _HomePageState extends State { // --- Static Data --- final List _funds = const [ - Fund(id: "aggressive", label: "アグレッシブファンド", annualReturn: 0.08), - Fund(id: "balanced", label: "バランスファンド", annualReturn: 0.06), - Fund(id: "conservative", label: "コンサバティブファンド", annualReturn: 0.04), - Fund(id: "growth", label: "グロースファンド", annualReturn: 0.09), - Fund(id: "income", label: "インカムファンド", annualReturn: 0.05), + Fund(id: "7203.T", label: "トヨタ自動車 (7203.T)", annualReturn: 0.04), + Fund(id: "^N225", label: "日経平均株価 (^N225)", annualReturn: 0.05), + Fund(id: "6758.T", label: "ソニーグループ (6758.T)", annualReturn: 0.05), + Fund(id: "8306.T", label: "三菱UFJフィナンシャルG (8306.T)", annualReturn: 0.03), + Fund(id: "7974.T", label: "任天堂 (7974.T)", annualReturn: 0.06), + Fund(id: "8035.T", label: "東京エレクトロン (8035.T)", annualReturn: 0.07), + Fund(id: "9984.T", label: "ソフトバンクグループ (9984.T)", annualReturn: 0.08), + Fund(id: "6861.T", label: "キーエンス (6861.T)", annualReturn: 0.06), + Fund(id: "4063.T", label: "信越化学工業 (4063.T)", annualReturn: 0.05), + Fund(id: "9432.T", label: "日本電信電話 (9432.T)", annualReturn: 0.03), ]; final List _events = const [ - LifeEvent(id: "retirement", label: "バブル崩壊"), - LifeEvent(id: "education", label: "コロナショック"), - LifeEvent(id: "housing", label: "トランプショック"), + LifeEvent(id: "china_shock_2015", label: "チャイナショック懸念 (2015)"), + LifeEvent(id: "corona_shock_2020", label: "コロナショック (2020)"), + LifeEvent(id: "yen_depreciation_2022", label: "円安・物価高 (2022)"), ]; final List _months = const [ "1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月" diff --git a/flutter_newokanelab/lib/services/api_service.dart b/flutter_newokanelab/lib/services/api_service.dart index 8eb88a55..5f1be59a 100644 --- a/flutter_newokanelab/lib/services/api_service.dart +++ b/flutter_newokanelab/lib/services/api_service.dart @@ -1,20 +1,61 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:flutter_newokanelab/core/api_config.dart'; import 'package:flutter_newokanelab/models/investment_models.dart'; class ApiService { - final String _baseUrl = "http://127.0.0.1:5000"; // Pythonサーバーのアドレス - + // 会社一覧を取得する Future> getCompanies() async { - final response = await http.get(Uri.parse('$_baseUrl/companies')); + try { + final response = await http.get(Uri.parse(ApiConfig.companiesEndpoint)); + + if (response.statusCode == 200) { + // 日本語が含まれる可能性があるため、UTF-8でデコード + final List jsonResponse = json.decode(utf8.decode(response.bodyBytes)); + return jsonResponse.map((company) => Company.fromJson(company)).toList(); + } else { + throw Exception('Failed to load companies: ${response.statusCode}'); + } + } catch (e) { + // 開発中などサーバーに繋がらない場合のエラーハンドリング + print("Error fetching companies: $e"); + rethrow; + } + } + + // シミュレーションを実行する + Future simulate({ + required double initialInvestment, + required double monthlyInvestment, + required List tickers, + required String startDate, + required String endDate, + }) async { + final Map requestBody = { + "initial_investment": initialInvestment, + "monthly_investment": monthlyInvestment, + "tickers": tickers, + "start_date": startDate, + "end_date": endDate, + }; + + try { + final response = await http.post( + Uri.parse(ApiConfig.simulateEndpoint), + headers: {"Content-Type": "application/json"}, + body: json.encode(requestBody), + ); - if (response.statusCode == 200) { - // 日本語が含まれる可能性があるため、UTF-8でデコード - final List jsonResponse = json.decode(utf8.decode(response.bodyBytes)); - return jsonResponse.map((company) => Company.fromJson(company)).toList(); - } else { - throw Exception('Failed to load companies'); + if (response.statusCode == 200) { + final Map jsonResponse = json.decode(utf8.decode(response.bodyBytes)); + return SimulationResponse.fromJson(jsonResponse); + } else { + throw Exception('Failed to run simulation: ${response.statusCode} ${response.body}'); + } + } catch (e) { + print("Error running simulation: $e"); + rethrow; } } } diff --git a/flutter_newokanelab/lib/services/simulation_service.dart b/flutter_newokanelab/lib/services/simulation_service.dart index 5c3b47af..bb3cdb4f 100644 --- a/flutter_newokanelab/lib/services/simulation_service.dart +++ b/flutter_newokanelab/lib/services/simulation_service.dart @@ -1,7 +1,9 @@ -import 'package:fl_chart/fl_chart.dart'; -import '../models/investment_models.dart'; +import 'package:flutter_newokanelab/models/investment_models.dart'; +import 'package:flutter_newokanelab/services/api_service.dart'; class SimulationService { + static final ApiService _apiService = ApiService(); + /// シミュレーションを実行し、結果を返す static Future calculate({ required double initialInvestment, @@ -10,40 +12,65 @@ class SimulationService { required List funds, required int simulationYears, }) async { - // 実際のアプリではここでAPI通信を行う。 - // この例では、2秒待つことで非同期処理を模倣。 - await Future.delayed(const Duration(seconds: 2)); - - // 選択されたファンドの情報を取得 - final fund = funds.firstWhere((f) => f.id == selectedFundId); - final returnRate = fund.annualReturn; - // グラフ描画用のデータを生成 - double projectionTotal = initialInvestment; - final List projectionData = []; - for (int year = 0; year <= simulationYears; year++) { - projectionData.add(FlSpot(year.toDouble(), projectionTotal.roundToDouble())); - projectionTotal = projectionTotal * (1 + returnRate) + averageMonthlyInvestment * 12; + // 1. ファンドIDに基づいて投資銘柄(Tickers)を決定する + List tickers; + switch (selectedFundId) { + case 'nikkei225': + // 日経平均株価連動 + tickers = ["^N225"]; + break; + case 'japan_core': + // 国内主力大型株 (トヨタ, ソニーG, 三菱UFJ) + tickers = ["7203.T", "6758.T", "8306.T"]; + break; + case 'us_tech': + // 米国テック大手 (Apple, Microsoft, Google) + tickers = ["AAPL", "MSFT", "GOOGL"]; + break; + case 'semi_growth': + // 半導体・グロース (東京エレクトロン, キーエンス, NVIDIA) + tickers = ["8035.T", "6861.T", "NVDA"]; + break; + case 'high_dividend': + // 高配当・バリュー (三菱商事, 武田薬品, NTT) + tickers = ["8058.T", "4502.T", "9432.T"]; + break; + default: + // デフォルト + tickers = ["^N225"]; } - // サマリー表示用のデータを生成 - double summaryTotal = initialInvestment; - for (int i = 1; i <= simulationYears; i++) { - summaryTotal = summaryTotal * (1 + returnRate) + averageMonthlyInvestment * 12; - } - final totalInvested = initialInvestment + averageMonthlyInvestment * 12 * simulationYears; - final profit = summaryTotal - totalInvested; - final profitRate = (profit / totalInvested) * 100; + // 2. シミュレーション期間の設定 + // バックエンドのデータが 2015-01-01 からあるため、そこを開始点とします。 + // 終了日は開始日から simulationYears 後、またはデータの終わり(2024年末)まで + const startYear = 2015; + final endYear = startYear + simulationYears; - // 計算結果をMapにまとめる - final summaryData = { - "totalAmount": summaryTotal, - "totalInvested": totalInvested, - "profit": profit, - "profitRate": profitRate, - }; + // データが存在する範囲にクリップする(バックエンドのデータが2024年までと仮定) + final effectiveEndYear = endYear > 2024 ? 2024 : endYear; + + final startDate = "$startYear-01-01"; + final endDate = "$effectiveEndYear-12-31"; - // SimulationResultオブジェクトとして返す - return SimulationResult(projectionData: projectionData, summaryData: summaryData); + try { + // 3. API呼び出し + final response = await _apiService.simulate( + initialInvestment: initialInvestment, + monthlyInvestment: averageMonthlyInvestment, + tickers: tickers, + startDate: startDate, + endDate: endDate, + ); + + // 4. 結果の変換 + return SimulationResult.fromResponse(response); + + } catch (e) { + print("Simulation failed: $e"); + // エラー時は空の結果などを返すか、再スローしてUI側でハンドリングさせます + // ここでは簡易的にエラーを再スローします + rethrow; + } } } diff --git a/flutter_newokanelab/pubspec.lock b/flutter_newokanelab/pubspec.lock index e8fb9f0c..92c2c5ce 100644 --- a/flutter_newokanelab/pubspec.lock +++ b/flutter_newokanelab/pubspec.lock @@ -91,6 +91,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" intl: dependency: "direct main" description: @@ -216,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -232,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.9.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/flutter_newokanelab/pubspec.yaml b/flutter_newokanelab/pubspec.yaml index 713659a0..7d8b7662 100644 --- a/flutter_newokanelab/pubspec.yaml +++ b/flutter_newokanelab/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: # iOSスタイルのアイコンフォント cupertino_icons: ^1.0.8 + http: ^1.6.0 dev_dependencies: flutter_test: