MAUI BlazorでCSVの”7,345.55″を正しく数値変換する方法|Split(‘,’)の罠とCultureInfo.InvariantCulture対策

.NET MAUI BlazorでCSVを読み込み、株価の終値(Close)をfloat(またはdecimal)に変換しようとしたとき、"7,345.55"のような「ダブルクォート+桁区切りカンマ+小数点」を含む値がうまく変換できず、なぜか4550のような不自然な値になることがあります。多くの場合、原因はTryParseではなく「CSVの分割方法」と「カルチャ(CultureInfo)を考慮していない数値パース」にあります。

目次

現象:"7,345.55"を数値にしたいだけなのに値が化ける

目的はシンプルで、CSVの「Close」列に入っている終値を数値として扱いたい、というものです。ところが実際には、次のような値が入っていることがあります。

  • 値がダブルクォートで囲まれている:"7,345.55"
  • 3桁ごとの桁区切りカンマがある:7,345.55
  • 小数点はピリオド:.55

ここで「カンマを消してTryParseすれば良い」と考えるのは自然です。しかし、実際に値が化けるときは、そこではなく別の場所がズレています。

見えている問題よくある誤解実際に起きていること
TryParseの結果が変数値パースが壊れているそもそも「Close列」ではない文字列をパースしている(列ズレ)
環境によって結果が変わるたまたま端末がおかしいCultureInfo.CurrentCulture依存で解釈が揺れている

まず確認:カンマ削除+TryParse自体は通常は正しい

以下のコードは、多くの環境では期待どおり7344.55になります。

using System;

string str = "7,344.55";
string newclsd = str.Replace(",", "");
bool ok = float.TryParse(newclsd, out float close);

Console.WriteLine(ok);     // True
Console.WriteLine(close);  // 7344.55(通常)

この時点で重要なのは、「この処理がダメ」なのではなく、入力に渡している文字列が本当にClose列なのか、またはカルチャをまたいでも同じ解釈になるようにしているか、を疑うべきという点です。

核心:Split(',')でCSVを分割すると、ダブルクォート内のカンマで列が壊れる

質問の実データの例は次のようなCSVです(株価データでよくある形式)。

"05-Mar-2025","EQ","1,056.80","1,118.65","1,055.00","1,057.95","1,112.45","1,112.70","1,103.00", ...

ここでのポイントは、数値がダブルクォートで囲まれていることです。CSVのルールでは、ダブルクォートで囲まれた中のカンマは区切り文字ではありません。しかし、次のように単純分割すると…

var columns = line.Split(',');

"1,112.45"が「1」と「112.45″」に割れてしまい、以降の列インデックスが全部ズレます。結果として、あなたが「Close列だ」と信じているcolumns[8]が、実は別の列(あるいは断片)になってしまいます。

列ズレが起きているかを一瞬で見抜くチェック方法

まずは「Closeをパースする前」に、分割結果をログに出してください。列数が想定より多い/少ない、あるいは一部の要素にクォートが残ったままなどの異常が見えるはずです。

for (int i = 0; i < columns.Length && i < 15; i++)
{
    Console.WriteLine($"{i}: [{columns[i]}]");
}

もし[1]や[112.45"のように、明らかに数値が割れていたら、原因は確定です。パースの前に、CSVの分割が壊れています。

解決策:CSV分割と数値パースを「仕様どおり」に直す

直すべき点は大きく2つです。

  • CSVはクォートを考慮して分割する(クォート外のカンマだけで区切る)
  • 数値は桁区切りと小数点を許可し、カルチャを固定してパースする
対処項目やること効果
CSV分割クォート外のカンマのみで分割列ズレが解消し、意図した列が取れる
数値パースNumberStyles.AllowThousands | NumberStyles.Float+InvariantCulture"1,112.70"を安全に1112.70へ変換
日付パースTryParseExactでフォーマット固定端末言語が変わっても同じ解釈になる

解決策①:正規表現で「ダブルクォート外のカンマ」だけで分割する

ライブラリを増やさずに最短で直すなら、正規表現で「クォートの外にあるカンマだけを区切り扱い」する方法が手軽です。以下は定番パターンです。

using System.Text.RegularExpressions;
using System.Linq;

string pattern = @",(?=(?:[^""]*""[^""]*"")*[^""]*$)";

var columns = Regex.Split(line, pattern)
                   .Select(s => s.Trim().Trim('"')) // 前後スペースとダブルクォートを除去
                   .ToArray();

この正規表現の意味はざっくり言うと、「行の末尾まで見たとき、クォートの数がつり合う位置のカンマだけを区切りにする」です。これにより、"1,112.45"のカンマは無視され、1つの列として扱われます。

注意点:正規表現は万能ではない

株価CSVのように「1行=1レコード」「フィールド内に改行がない」前提なら、正規表現でも十分実用的です。一方で、フィールド内改行・エスケープされたクォート("")が複雑に混ざるCSVを扱う場合は、後述のCSV専用ライブラリ(CsvHelper等)の方が安全です。

解決策②:桁区切りカンマを許可し、カルチャを固定して数値をパースする

CSVの数値が英語表記(桁区切りはカンマ、小数点はドット)で出てくるなら、数値パースはInvariantCulture固定にするのが鉄則です。Replace(",", "")でカンマを消す必要すらありません。

using System.Globalization;

bool isCloseParsed = float.TryParse(
    columns[8],
    NumberStyles.AllowThousands | NumberStyles.Float,
    CultureInfo.InvariantCulture,
    out var close
);

ここで指定しているオプションの意味は次のとおりです。

指定許可される表記このケースでの役割
NumberStyles.AllowThousands1,112のような桁区切り1,112.45を弾かずに受け入れる
NumberStyles.Float小数点、符号、指数など1112.70の小数を正しく扱う
CultureInfo.InvariantCultureOS言語に依存しない固定ルール端末の言語設定差で事故らない

株価・金額ならfloatよりdecimalも検討する

チャート描画などで多少の誤差が許容されるならfloatでも動きますが、金額は二進浮動小数点の都合で誤差が出やすいため、計算や集計をするならdecimalが無難です。decimalでも同じ考え方でパースできます。

bool isCloseParsed = decimal.TryParse(
    columns[8],
    NumberStyles.AllowThousands | NumberStyles.Number,
    CultureInfo.InvariantCulture,
    out var close
);

解決策③:日付もTryParseExactでフォーマット固定にする

日付が"05-Mar-2025"のように「英語の月略称」を含む場合、端末の言語設定によって解釈が揺れる可能性があります。ここもInvariantCultureで固定します。

using System.Globalization;

bool isDateParsed = DateTime.TryParseExact(
    columns[0],
    "dd-MMM-yyyy",
    CultureInfo.InvariantCulture,
    DateTimeStyles.None,
    out var date
);

解決策④:成功した行だけを採用し、失敗行は捨てる(または記録する)

CSVは「空行」「途中で壊れた行」「予想外のフォーマット」が混ざることがあります。すべてを完璧に読み込もうとすると、UI側で例外が出たり、データ全体が崩れたりします。最低限、日付とCloseが取れた行だけ採用するのが安定します。

var data = new List<StockData>();

if (isDateParsed && isCloseParsed)
{
    data.Add(new StockData
    {
        Date  = date,
        Close = close
    });
}

加えて、運用上は「失敗した行のログ」を残すと原因究明が早くなります。

if (!isDateParsed || !isCloseParsed)
{
    // 例:デバッグ用に残す(本番ではログ基盤へ)
    Console.WriteLine($"Parse failed: date=[{columns[0]}], close=[{columns[8]}]");
}

実装例:1行を安全に分解し、日付と終値を取り出す最小構成

以下は「正規表現で分割」+「InvariantCultureで数値をパース」+「日付をTryParseExact」で固定、という流れを1つにまとめた例です。MAUI Blazorでもそのまま流用しやすい形にしています。

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;

public sealed class StockData
{
    public DateTime Date { get; set; }
    public float Close { get; set; } // 金額計算するならdecimal推奨
}

public static class StockCsvParser
{
    // クォート外のカンマのみで分割
    private static readonly Regex CsvSplitRegex =
        new Regex(@",(?=(?:[^""]*""[^""]*"")*[^""]*$)", RegexOptions.Compiled);

    public static bool TryParseLine(string line, int closeIndex, out StockData? stock)
    {
        stock = null;

        if (string.IsNullOrWhiteSpace(line))
            return false;

        // 改行末尾の\r対策(Windows系のCSVでありがち)
        line = line.TrimEnd('\r', '\n');

        var columns = CsvSplitRegex
            .Split(line)
            .Select(s => s.Trim().Trim('"'))
            .ToArray();

        // 期待する列数が足りなければ失敗
        if (columns.Length <= closeIndex)
            return false;

        // 日付
        if (!DateTime.TryParseExact(
                columns[0],
                "dd-MMM-yyyy",
                CultureInfo.InvariantCulture,
                DateTimeStyles.None,
                out var date))
        {
            return false;
        }

        // Close(桁区切りカンマ+小数点ドットを許可)
        if (!float.TryParse(
                columns[closeIndex],
                NumberStyles.AllowThousands | NumberStyles.Float,
                CultureInfo.InvariantCulture,
                out var close))
        {
            return false;
        }

        stock = new StockData { Date = date, Close = close };
        return true;
    }

    public static List<StockData> ParseAllLines(IEnumerable<string> lines, int closeIndex)
    {
        var list = new List<StockData>();

        foreach (var line in lines)
        {
            if (TryParseLine(line, closeIndex, out var stock) && stock is not null)
                list.Add(stock);
        }

        return list;
    }
}

「Close列のインデックス」を固定するのが不安なら、ヘッダーから特定する

CSVにヘッダーがある場合は、columns[8]のように列番号を直書きすると、提供元が列順を変えた瞬間に壊れます。ヘッダー名からインデックスを特定するだけで、保守性が一段上がります。

int GetColumnIndex(string headerLine, string columnName)
{
    string pattern = @",(?=(?:[^""]*""[^""]*"")*[^""]*$)";
    var headers = Regex.Split(headerLine.TrimEnd('\r','\n'), pattern)
                       .Select(s => s.Trim().Trim('"'))
                       .ToArray();

    return Array.FindIndex(headers, h => string.Equals(h, columnName, StringComparison.OrdinalIgnoreCase));
}

例えばヘッダーにCloseという列名があるなら、次のようにして取得します。

int closeIndex = GetColumnIndex(headerLine, "Close");
if (closeIndex < 0) throw new InvalidOperationException("Close列が見つかりません。");

日本語環境でハマりやすい「カルチャ」と「表示」の分離

MAUIアプリは、端末の言語設定(CultureInfo.CurrentCulture)の影響を受けます。読み込み時にカルチャ依存の処理をしてしまうと、テスト端末では動くのに、別端末で突然壊れる…という事故が起きます。

  • 読み込み(パース)は固定(InvariantCulture):データ解釈をブレさせない
  • 表示はCurrentCulture:ユーザーの言語圏に合わせて見やすくする

例えば、日本語表示に寄せたいなら「パース後」にフォーマットします。

@close.ToString("N2", CultureInfo.CurrentCulture)

この構成にしておくと、ロジックは常に安定し、UIはユーザーに優しい表示になります。

より確実にするなら:CSV専用ライブラリ(CsvHelper)を使う

正規表現分割は便利ですが、CSVは想像以上に奥が深いフォーマットです。より堅牢にするなら、NuGetでCsvHelperのような定番ライブラリを使うのが実務的です。

  • クォートやエスケープ、欠損値などの例外ケースに強い
  • 列名マッピングが簡単(ヘッダーの有無にも対応しやすい)
  • カルチャを明示できるため、パースの再現性が高い

概念的には次のような流れになります(例:必要フィールドだけをGetFieldで取り出す)。

using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
    HasHeaderRecord = false,   // ヘッダーがあるならtrue+ReadHeader()
    Delimiter = ",",
    Quote = '"',
    BadDataFound = null,
    MissingFieldFound = null
};

// readerはStreamReaderやStringReaderなど
using var csv = new CsvReader(reader, config);

while (csv.Read())
{
    var dateText  = csv.GetField(0);
    var closeText = csv.GetField(8); // 実データに合わせる

    // dateText/closeTextをTryParseExactやTryParseで処理
}

「株価CSVは提供元によって列が増える」「たまに空欄がある」「クォートの扱いが微妙に違う」など、将来的に揺れそうなら、最初からCSVライブラリに寄せる方がトータルコストが下がりやすいです。

トラブルを再発させないチェックリスト

  • CSVをSplit(',')していないか?(クォートを考慮しているか)
  • パース前に列数と内容をログで確認しているか?(Close列に本当にCloseが入っているか)
  • 数値はAllowThousands+InvariantCultureでパースしているか?
  • 日付はTryParseExactでフォーマット固定しているか?
  • 読み込み(Invariant)と表示(Current)を分離しているか?

「カンマを消してTryParseしているのに変な数値になる」ケースの多くは、実はTryParseの問題ではなく、CSVの分割で列が壊れて別の値をパースしていることが原因です。分割を正しくし、カルチャを固定してパースする。この2点を押さえるだけで、MAUI BlazorでのCSV取り込みは一気に安定します。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次