.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.AllowThousands | 1,112のような桁区切り | 1,112.45を弾かずに受け入れる |
NumberStyles.Float | 小数点、符号、指数など | 1112.70の小数を正しく扱う |
CultureInfo.InvariantCulture | OS言語に依存しない固定ルール | 端末の言語設定差で事故らない |
株価・金額なら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取り込みは一気に安定します。

コメント