Преглед на файлове

feat(paper): D4 M4(admin) — PaperConfig 편집 + 계좌/주문 모니터

- Config Get/Update: PaperConfig 노출 + UpdatePaper() 배선
  (request.Paper != null 가드로 부분 저장 시 null-wipe 방지, ActivityToken 패턴).
- Admin /Config/Paper: 모의투자 설정 편집(활성화·수수료·거래세·최소입금·
  최대보유·출금수익소각·1주문상한·등재최소체결수).
- Admin /Paper/Accounts: 계좌 모니터(회원·토큰·예약·좌수·입출금누계, 검색·페이징).
- Admin /Paper/Orders: 주문 모니터(회원·종목·Side·상태·기준일, 상태필터·페이징).
  둘 다 읽기전용, IAppDbContext 직접 + Pagination(ChatVisitor 선례).

빌드 0 에러, 테스트 101/101.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 преди 2 седмици
родител
ревизия
d70221bf91

+ 105 - 0
Admin/Pages/Config/Paper/Index.cshtml

@@ -0,0 +1,105 @@
+@page
+@model Admin.Pages.Config.Paper.IndexModel
+
+@{
+    ViewData["Title"] = "모의투자 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
+
+        <!-- 모의투자 설정 -->
+        <details open>
+            <summary class="fs-5 mb-3">모의투자 설정</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.Enabled" class="col-sm-3 col-form-label">모의투자 활성화</label>
+                <div class="col-sm-3 d-flex align-items-center">
+                    <div class="form-check form-switch">
+                        <input asp-for="Input.Paper.Enabled" class="form-check-input" type="checkbox" role="switch" />
+                    </div>
+                    <div class="form-text text-muted ms-2 mb-0">모의투자 노출 및 주문 접수 토글.</div>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.FeeRateBp" class="col-sm-3 col-form-label">매매 수수료(Bp)</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.FeeRateBp" type="number" class="form-control" min="0" max="100000" />
+                    <div class="form-text text-muted">매매 수수료. 15 = 0.15% (토큰 sink).</div>
+                    <span asp-validation-for="Input.Paper.FeeRateBp" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.TaxRateBp" class="col-sm-3 col-form-label">거래세(Bp)</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.TaxRateBp" type="number" class="form-control" min="0" max="100000" />
+                    <div class="form-text text-muted">매도 거래세. 18 = 0.18% (토큰 sink).</div>
+                    <span asp-validation-for="Input.Paper.TaxRateBp" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.MinDeposit" class="col-sm-3 col-form-label">최소 입금 토큰</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.MinDeposit" type="number" class="form-control" min="0" max="1000000000" />
+                    <div class="form-text text-muted">1회 최소 입금 토큰.</div>
+                    <span asp-validation-for="Input.Paper.MinDeposit" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.MaxHolding" class="col-sm-3 col-form-label">최대 보유 토큰(0=무제한)</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.MaxHolding" type="number" class="form-control" min="0" max="2000000000" />
+                    <div class="form-text text-muted">계좌 최대 보유 토큰. 0이면 무제한입니다.</div>
+                    <span asp-validation-for="Input.Paper.MaxHolding" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.WithdrawProfitBurnBp" class="col-sm-3 col-form-label">출금 수익 소각(Bp, 0=사용안함)</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.WithdrawProfitBurnBp" type="number" class="form-control" min="0" max="10000" />
+                    <div class="form-text text-muted">출금 시 수익분 소각 비율(인플레 밸브). 0이면 사용하지 않습니다.</div>
+                    <span asp-validation-for="Input.Paper.WithdrawProfitBurnBp" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.OrderMaxPctBp" class="col-sm-3 col-form-label">1주문 상한(Bp, 3000=30%)</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.OrderMaxPctBp" type="number" class="form-control" min="0" max="10000" />
+                    <div class="form-text text-muted">1주문 금액 상한 = 계좌 평가자산 × 비율. 3000 = 30%.</div>
+                    <span asp-validation-for="Input.Paper.OrderMaxPctBp" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Paper.MinFillsForRank" class="col-sm-3 col-form-label">리더보드 등재 최소 체결수</label>
+                <div class="col-sm-3">
+                    <input asp-for="Input.Paper.MinFillsForRank" type="number" class="form-control" min="0" max="100000" />
+                    <div class="form-text text-muted">리더보드에 등재되기 위한 최소 체결(매도) 수.</div>
+                    <span asp-validation-for="Input.Paper.MinFillsForRank" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>

+ 55 - 0
Admin/Pages/Config/Paper/Index.cshtml.cs

@@ -0,0 +1,55 @@
+using Application.Abstractions.Messaging;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config.Paper;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        var config = await mediator.Send(new GetConfig.Query(), ct);
+        if (config is not null)
+        {
+            Input = InputModel.From(config);
+        }
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return Page();
+        }
+
+        await mediator.Send(Input.ToCommand(), ct);
+
+        TempData["SuccessMessage"] = "저장되었습니다.";
+        return RedirectToPage();
+    }
+
+    public sealed class InputModel
+    {
+        public UpdateConfig.Request.PaperConfigDto Paper { get; set; } = new();
+
+        public static InputModel From(GetConfig.Response config)
+        {
+            var req = UpdateConfig.Request.From(config);
+
+            return new()
+            {
+                Paper = req.Paper ?? new()
+            };
+        }
+
+        public UpdateConfig.Command ToCommand()
+        {
+            return new(
+                Paper: Paper
+            );
+        }
+    }
+}

+ 125 - 0
Admin/Pages/Paper/Accounts/Index.cshtml

@@ -0,0 +1,125 @@
+@page
+@model Admin.Pages.Paper.Accounts.IndexModel
+@{
+    ViewData["Title"] = "모의투자 계좌 모니터";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 mb-2 align-items-center">
+        <div class="col-auto search-form__label">
+            <label class="col-form-label">검색어</label>
+        </div>
+        <div class="col-12 col-lg-auto">
+            <div class="row g-2">
+                <div class="col-auto col-md-auto">
+                    <select id="search" class="form-select">
+                        <option value="">검색 조건</option>
+                        <option value="1" selected="@(Model.Query.Search == 1)">회원 SID</option>
+                        <option value="2" selected="@(Model.Query.Search == 2)">닉네임</option>
+                    </select>
+                </div>
+                <div class="col col-md-auto">
+                    <input type="search" id="keyword" class="form-control" maxlength="100" value="@Model.Query.Keyword" placeholder="검색어를 입력하세요." />
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="row justify-content-center mt-3">
+        <div class="col col-sm-auto">
+            <button type="submit" id="btnSearch" class="btn btn-primary w-100">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 mb-2">
+        <div class="col">
+            Total : @Model.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-bordered table-hover">
+            <thead>
+                <tr>
+                    <th style="width:5%">#</th>
+                    <th>회원 SID</th>
+                    <th>닉네임</th>
+                    <th class="text-end">토큰</th>
+                    <th class="text-end">예약 토큰</th>
+                    <th class="text-end">좌수</th>
+                    <th class="text-end">누적 입금</th>
+                    <th class="text-end">누적 출금</th>
+                    <th style="width:14%">개설일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tr><td colspan="9">No Data.</td></tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>@row.Num</td>
+                            <td><code>@row.SID</code></td>
+                            <td>@(row.Nickname ?? "-")</td>
+                            <td class="text-end">@row.Token.ToString("N0")</td>
+                            <td class="text-end">@row.ReservedToken.ToString("N0")</td>
+                            <td class="text-end">@row.Units.ToString("N8")</td>
+                            <td class="text-end">@row.TotalDeposited.ToString("N0")</td>
+                            <td class="text-end">@row.TotalWithdrawn.ToString("N0")</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        function updateQueryString() {
+            let queryParams = new URLSearchParams();
+
+            queryParams.set("Query.Search", document.getElementById("search").value);
+            queryParams.set("Query.Keyword", document.getElementById("keyword").value);
+            queryParams.set("Query.PerPage", document.getElementById("perPage").value);
+
+            window.location.href = window.location.pathname + "?" + queryParams.toString();
+        }
+
+        $(document).on("change", "#perPage", updateQueryString);
+
+        $(document).on("click", "#btnSearch", function(e) {
+            e.preventDefault();
+            updateQueryString();
+        });
+
+        $(document).on("keyup", "#keyword", function(e) {
+            if (e.which === 13 || e.key === "Enter") {
+                e.preventDefault();
+                updateQueryString();
+            }
+        });
+    </script>
+}

+ 107 - 0
Admin/Pages/Paper/Accounts/Index.cshtml.cs

@@ -0,0 +1,107 @@
+using Application.Abstractions.Data;
+using SharedKernel.Helpers;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Paper.Accounts;
+
+// 모의투자 계좌 모니터 (읽기 전용) — d4 §⑤ /Paper/Accounts
+public class IndexModel(IAppDbContext db) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        public ushort PerPage { get; set; } = 20;
+
+        // 1 = 회원 SID, 2 = 닉네임
+        [DisplayName("검색 조건")]
+        [Range(1, 2, ErrorMessage = "{0}이(가) 올바르지 않습니다.")]
+        public int? Search { get; set; }
+
+        [DisplayName("검색어")]
+        [MaxLength(100)]
+        public string? Keyword { get; set; }
+    }
+
+    public int Total { get; set; }
+
+    public List<(
+        int Num,
+        int ID,
+        string SID,
+        string? Nickname,
+        decimal Token,
+        decimal ReservedToken,
+        decimal Units,
+        decimal TotalDeposited,
+        decimal TotalWithdrawn,
+        string CreatedAt
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var query = from a in db.PaperAccount.AsNoTracking()
+                    join m in db.Member.AsNoTracking() on a.MemberID equals m.ID
+                    select new
+                    {
+                        a.ID,
+                        m.SID,
+                        Nickname = m.Name,
+                        a.Token,
+                        a.ReservedToken,
+                        a.Units,
+                        a.TotalDeposited,
+                        a.TotalWithdrawn,
+                        a.CreatedAt
+                    };
+
+        if (!string.IsNullOrWhiteSpace(Query.Keyword))
+        {
+            var keyword = Query.Keyword.Trim();
+
+            query = Query.Search switch
+            {
+                1 => query.Where(c => c.SID == keyword),
+                2 => query.Where(c => c.Nickname != null && c.Nickname.Contains(keyword)),
+                _ => query
+            };
+        }
+
+        Total = await query.CountAsync(ct);
+
+        var skip = (Query.PageNum - 1) * Query.PerPage;
+        var rows = await query.OrderByDescending(c => c.ID).Skip(skip).Take(Query.PerPage).ToListAsync(ct);
+        var num = skip + 1;
+
+        List = [.. rows.Select(c => (
+            Num: num++,
+            c.ID,
+            c.SID,
+            c.Nickname,
+            c.Token,
+            c.ReservedToken,
+            c.Units,
+            c.TotalDeposited,
+            c.TotalWithdrawn,
+            CreatedAt: c.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
+        ))];
+
+        Pagination = new Pagination(Total, Query.PageNum, Query.PerPage);
+    }
+}

+ 120 - 0
Admin/Pages/Paper/Orders/Index.cshtml

@@ -0,0 +1,120 @@
+@page
+@model Admin.Pages.Paper.Orders.IndexModel
+@using Domain.Entities.Paper.ValueObject
+@{
+    ViewData["Title"] = "모의투자 주문 모니터";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 mb-2 align-items-center">
+        <div class="col-auto search-form__label">
+            <label class="col-form-label">주문 상태</label>
+        </div>
+        <div class="col-auto">
+            <select id="status" class="form-select w-auto d-inline-block">
+                <option value="">전체</option>
+                <option value="@((int)PaperOrderStatus.Pending)" selected="@(Model.Query.Status == PaperOrderStatus.Pending)">접수</option>
+                <option value="@((int)PaperOrderStatus.Filled)" selected="@(Model.Query.Status == PaperOrderStatus.Filled)">체결</option>
+                <option value="@((int)PaperOrderStatus.Cancelled)" selected="@(Model.Query.Status == PaperOrderStatus.Cancelled)">취소</option>
+                <option value="@((int)PaperOrderStatus.Rejected)" selected="@(Model.Query.Status == PaperOrderStatus.Rejected)">거부</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="submit" id="btnSearch" class="btn btn-primary">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 mb-2">
+        <div class="col">
+            Total : @Model.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-bordered table-hover">
+            <thead>
+                <tr>
+                    <th style="width:5%">#</th>
+                    <th>회원</th>
+                    <th>종목코드</th>
+                    <th>구분</th>
+                    <th>체결가</th>
+                    <th class="text-end">수량</th>
+                    <th class="text-end">예약금</th>
+                    <th>체결기준일</th>
+                    <th>상태</th>
+                    <th>거부사유</th>
+                    <th style="width:14%">주문일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
+                    <tr><td colspan="11">No Data.</td></tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>@row.Num</td>
+                            <td>
+                                <code>@row.SID</code>
+                                <div class="text-muted small">@(row.Nickname ?? "-")</div>
+                            </td>
+                            <td><code>@row.StockCode</code></td>
+                            <td>@Admin.Pages.Paper.Orders.IndexModel.SideToText(row.Side)</td>
+                            <td>@Admin.Pages.Paper.Orders.IndexModel.FillRuleToText(row.FillRule)</td>
+                            <td class="text-end">@row.Quantity.ToString("N0")</td>
+                            <td class="text-end">@row.ReservedAmount.ToString("N0")</td>
+                            <td>@row.TargetDate</td>
+                            <td>@row.StatusText</td>
+                            <td>@(row.RejectReason ?? "-")</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+@section Scripts {
+    <script>
+        function updateQueryString() {
+            let queryParams = new URLSearchParams();
+
+            const status = document.getElementById("status").value;
+            if (status !== "") {
+                queryParams.set("Query.Status", status);
+            }
+            queryParams.set("Query.PerPage", document.getElementById("perPage").value);
+
+            window.location.href = window.location.pathname + "?" + queryParams.toString();
+        }
+
+        $(document).on("change", "#perPage, #status", updateQueryString);
+
+        $(document).on("click", "#btnSearch", function(e) {
+            e.preventDefault();
+            updateQueryString();
+        });
+    </script>
+}

+ 130 - 0
Admin/Pages/Paper/Orders/Index.cshtml.cs

@@ -0,0 +1,130 @@
+using Application.Abstractions.Data;
+using Domain.Entities.Paper.ValueObject;
+using SharedKernel.Helpers;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Paper.Orders;
+
+// 모의투자 주문 모니터 (읽기 전용) — d4 §⑤ /Paper/Orders
+public class IndexModel(IAppDbContext db) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        public ushort PerPage { get; set; } = 20;
+
+        [DisplayName("주문 상태")]
+        public PaperOrderStatus? Status { get; set; }
+    }
+
+    public int Total { get; set; }
+
+    public List<(
+        int Num,
+        int ID,
+        string SID,
+        string? Nickname,
+        string StockCode,
+        PaperOrderSide Side,
+        PaperFillRule FillRule,
+        int Quantity,
+        decimal ReservedAmount,
+        string TargetDate,
+        PaperOrderStatus Status,
+        string StatusText,
+        string? RejectReason,
+        string CreatedAt
+    )> List { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var query = from o in db.PaperOrder.AsNoTracking()
+                    join a in db.PaperAccount.AsNoTracking() on o.AccountID equals a.ID
+                    join m in db.Member.AsNoTracking() on a.MemberID equals m.ID
+                    select new
+                    {
+                        o.ID,
+                        m.SID,
+                        Nickname = m.Name,
+                        o.StockCode,
+                        o.Side,
+                        o.FillRule,
+                        o.Quantity,
+                        o.ReservedAmount,
+                        o.TargetDate,
+                        o.Status,
+                        o.RejectReason,
+                        o.CreatedAt
+                    };
+
+        if (Query.Status.HasValue)
+        {
+            query = query.Where(c => c.Status == Query.Status.Value);
+        }
+
+        Total = await query.CountAsync(ct);
+
+        var skip = (Query.PageNum - 1) * Query.PerPage;
+        var rows = await query.OrderByDescending(c => c.ID).Skip(skip).Take(Query.PerPage).ToListAsync(ct);
+        var num = skip + 1;
+
+        List = [.. rows.Select(c => (
+            Num: num++,
+            c.ID,
+            c.SID,
+            c.Nickname,
+            c.StockCode,
+            c.Side,
+            c.FillRule,
+            c.Quantity,
+            c.ReservedAmount,
+            TargetDate: c.TargetDate.ToString("yyyy-MM-dd"),
+            c.Status,
+            StatusText: StatusToText(c.Status),
+            c.RejectReason,
+            CreatedAt: c.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
+        ))];
+
+        Pagination = new Pagination(Total, Query.PageNum, Query.PerPage);
+    }
+
+    public static string StatusToText(PaperOrderStatus s) => s switch
+    {
+        PaperOrderStatus.Pending => "접수",
+        PaperOrderStatus.Filled => "체결",
+        PaperOrderStatus.Cancelled => "취소",
+        PaperOrderStatus.Rejected => "거부",
+        _ => "?"
+    };
+
+    public static string SideToText(PaperOrderSide s) => s switch
+    {
+        PaperOrderSide.Buy => "매수",
+        PaperOrderSide.Sell => "매도",
+        _ => "?"
+    };
+
+    public static string FillRuleToText(PaperFillRule r) => r switch
+    {
+        PaperFillRule.Open => "시가",
+        PaperFillRule.Close => "종가",
+        _ => "?"
+    };
+}

+ 12 - 0
Application/Features/Config/Get/Handler.cs

@@ -174,6 +174,18 @@ public sealed class Handler(IAppDbContext db, ICacheService cache) : IQueryHandl
                 PostToken = config.ActivityToken.PostToken,
                 CommentToken = config.ActivityToken.CommentToken,
                 DailyTokenCap = config.ActivityToken.DailyTokenCap
+            },
+
+            Paper = new Response.PaperConfigDto
+            {
+                Enabled = config.Paper.Enabled,
+                FeeRateBp = config.Paper.FeeRateBp,
+                TaxRateBp = config.Paper.TaxRateBp,
+                MinDeposit = config.Paper.MinDeposit,
+                MaxHolding = config.Paper.MaxHolding,
+                WithdrawProfitBurnBp = config.Paper.WithdrawProfitBurnBp,
+                OrderMaxPctBp = config.Paper.OrderMaxPctBp,
+                MinFillsForRank = config.Paper.MinFillsForRank
             }
         };
 

+ 28 - 0
Application/Features/Config/Get/Response.cs

@@ -18,6 +18,7 @@ public sealed class Response
     public AttendanceConfigDto Attendance { get; init; } = new();
     public SignupRewardConfigDto SignupReward { get; init; } = new();
     public ActivityTokenConfigDto ActivityToken { get; init; } = new();
+    public PaperConfigDto Paper { get; init; } = new();
 
     public sealed class BasicConfigDto
     {
@@ -402,4 +403,31 @@ public sealed class Response
         [DisplayName("일일 토큰 상한")]
         public int DailyTokenCap { get; init; } = 0;
     }
+
+    public sealed class PaperConfigDto
+    {
+        [DisplayName("모의투자 활성화")]
+        public bool Enabled { get; init; } = true;
+
+        [DisplayName("매매 수수료(Bp)")]
+        public int FeeRateBp { get; init; } = 15;
+
+        [DisplayName("거래세(Bp)")]
+        public int TaxRateBp { get; init; } = 18;
+
+        [DisplayName("최소 입금 토큰")]
+        public int MinDeposit { get; init; } = 10000;
+
+        [DisplayName("최대 보유 토큰(0=무제한)")]
+        public int MaxHolding { get; init; } = 0;
+
+        [DisplayName("출금 수익 소각(Bp, 0=사용안함)")]
+        public int WithdrawProfitBurnBp { get; init; } = 0;
+
+        [DisplayName("1주문 상한(Bp, 3000=30%)")]
+        public int OrderMaxPctBp { get; init; } = 3000;
+
+        [DisplayName("리더보드 등재 최소 체결수")]
+        public int MinFillsForRank { get; init; } = 3;
+    }
 }

+ 1 - 0
Application/Features/Config/Update/Command.cs

@@ -14,6 +14,7 @@ public sealed record Command(
     Request.AttendanceConfigDto? Attendance = null,
     Request.SignupRewardConfigDto? SignupReward = null,
     Request.ActivityTokenConfigDto? ActivityToken = null,
+    Request.PaperConfigDto? Paper = null,
     Command.ImagesDeleteFlags? ImagesDelete = null
 ) : ICommand {
     public sealed record ImagesDeleteFlags(

+ 16 - 0
Application/Features/Config/Update/Handler.cs

@@ -230,6 +230,22 @@ public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImage
             });
         }
 
+        // 모의투자 설정도 별도 메서드로 갱신 — 부분 저장 시 null 덮어쓰기 방지 (d4 M4)
+        if (request.Paper != null)
+        {
+            config.UpdatePaper(new PaperConfig
+            {
+                Enabled = request.Paper.Enabled,
+                FeeRateBp = request.Paper.FeeRateBp,
+                TaxRateBp = request.Paper.TaxRateBp,
+                MinDeposit = request.Paper.MinDeposit,
+                MaxHolding = request.Paper.MaxHolding,
+                WithdrawProfitBurnBp = request.Paper.WithdrawProfitBurnBp,
+                OrderMaxPctBp = request.Paper.OrderMaxPctBp,
+                MinFillsForRank = request.Paper.MinFillsForRank
+            });
+        }
+
         await db.SaveChangesAsync(ct);
         await cache.RemoveAsync(CacheKeys.Config, ct);
     }

+ 48 - 0
Application/Features/Config/Update/Request.cs

@@ -18,6 +18,7 @@ public sealed class Request
     public AttendanceConfigDto? Attendance { get; init; }
     public SignupRewardConfigDto? SignupReward { get; init; }
     public ActivityTokenConfigDto? ActivityToken { get; init; }
+    public PaperConfigDto? Paper { get; init; }
 
     // 기본 설정
     public sealed class BasicConfigDto
@@ -527,6 +528,41 @@ public sealed class Request
         public int DailyTokenCap { get; init; } = 0;
     }
 
+    // 모의투자 설정 (Paper) — d4 §④
+    public sealed class PaperConfigDto
+    {
+        [DisplayName("모의투자 활성화")]
+        public bool Enabled { get; init; } = true;
+
+        [Range(0, 100000)]
+        [DisplayName("매매 수수료(Bp)")]
+        public int FeeRateBp { get; init; } = 15;
+
+        [Range(0, 100000)]
+        [DisplayName("거래세(Bp)")]
+        public int TaxRateBp { get; init; } = 18;
+
+        [Range(0, 1000000000)]
+        [DisplayName("최소 입금 토큰")]
+        public int MinDeposit { get; init; } = 10000;
+
+        [Range(0, 2000000000)]
+        [DisplayName("최대 보유 토큰(0=무제한)")]
+        public int MaxHolding { get; init; } = 0;
+
+        [Range(0, 10000)]
+        [DisplayName("출금 수익 소각(Bp, 0=사용안함)")]
+        public int WithdrawProfitBurnBp { get; init; } = 0;
+
+        [Range(0, 10000)]
+        [DisplayName("1주문 상한(Bp, 3000=30%)")]
+        public int OrderMaxPctBp { get; init; } = 3000;
+
+        [Range(0, 100000)]
+        [DisplayName("리더보드 등재 최소 체결수")]
+        public int MinFillsForRank { get; init; } = 3;
+    }
+
     public static Request From(Get.Response src) => new()
     {
         ID = src.ID,
@@ -677,6 +713,18 @@ public sealed class Request
             PostToken = src.ActivityToken.PostToken,
             CommentToken = src.ActivityToken.CommentToken,
             DailyTokenCap = src.ActivityToken.DailyTokenCap
+        },
+
+        Paper = new PaperConfigDto
+        {
+            Enabled = src.Paper.Enabled,
+            FeeRateBp = src.Paper.FeeRateBp,
+            TaxRateBp = src.Paper.TaxRateBp,
+            MinDeposit = src.Paper.MinDeposit,
+            MaxHolding = src.Paper.MaxHolding,
+            WithdrawProfitBurnBp = src.Paper.WithdrawProfitBurnBp,
+            OrderMaxPctBp = src.Paper.OrderMaxPctBp,
+            MinFillsForRank = src.Paper.MinFillsForRank
         }
     };
 }