diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 05:23:52 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 05:23:52 +0800 |
| commit | 6abd4c206fbfe8369b3fde4c905619d08f13c5d1 (patch) | |
| tree | f53ce1746591b640f78cd11d63c8411160059d98 /src | |
| parent | 545f2a507fd0138f7fd50ed7fb2631d0273db625 (diff) | |
| download | fund-lab-6abd4c206fbfe8369b3fde4c905619d08f13c5d1.tar.gz | |
Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 55 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 105 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 146 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 20 |
4 files changed, 321 insertions, 5 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 138230f..5ff7fdd 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -1505,6 +1505,29 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx + let private setSipPlanStatus + (repository: FundRepository) + (targetStatus: string) + (fundIdText: string) + (planIdText: string) + : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_SIP_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + match repository.SetSipPlanStatus(fundId, planId, targetStatus) with + | SipPlanStatusResult.SipPlanStatusChanged plan -> json (sipPlanResponse plan) next ctx + | SipPlanStatusResult.SipPlanStatusNotFound -> + errorResponse 404 "PLAN_NOT_FOUND" "sip plan does not belong to this fund" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "sip plan status update failed" next ctx + let private createInvestmentPlan (repository: FundRepository) (fundIdText: string) : HttpHandler = fun next ctx -> task { @@ -1735,6 +1758,24 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan persistence failed" next ctx + let private archiveRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_REBALANCE_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + match repository.ArchiveRebalancePlan(fundId, planId) with + | RebalanceArchiveResult.RebalancePlanArchived plan -> json (rebalancePlanResponse plan) next ctx + | RebalanceArchiveResult.RebalanceArchiveNotFound -> + errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan archive failed" next ctx + let private executeRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = fun next ctx -> match Guid.TryParse fundIdText, Guid.TryParse planIdText with @@ -1749,7 +1790,10 @@ module App = | Some _ -> match repository.ExecuteRebalancePlan planId with | Error message -> - errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx + if message.Contains("is archived") then + errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx + else + errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx | Ok result -> if result.PlanId <> Guid.Empty && repository.GetRebalancePlans fundId |> List.exists (fun plan -> plan.Id = result.PlanId) |> not then errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx @@ -1787,7 +1831,11 @@ module App = errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx else match repository.PreviewRebalancePlan planId with - | Error message -> errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx + | Error message -> + if message.Contains("is archived") then + errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx + else + errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx | Ok preview -> json (rebalancePreviewResponse preview) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance preview failed" next ctx @@ -2275,8 +2323,11 @@ module App = POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository) GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository) POST >=> routef "/funds/%s/sip/advance" (advanceSipPlans repository) + POST >=> routef "/funds/%s/sip/plans/%s/pause" (fun (fundId, planId) -> setSipPlanStatus repository "paused" fundId planId) + POST >=> routef "/funds/%s/sip/plans/%s/resume" (fun (fundId, planId) -> setSipPlanStatus repository "active" fundId planId) POST >=> routef "/funds/%s/rebalance/plans" (createRebalancePlan repository) GET >=> routef "/funds/%s/rebalance/plans" (getRebalancePlans repository) + POST >=> routef "/funds/%s/rebalance/plans/%s/archive" (fun (fundId, planId) -> archiveRebalancePlan repository fundId planId) POST >=> routef "/funds/%s/rebalance/plans/%s/execute" (fun (fundId, planId) -> executeRebalancePlan repository fundId planId) GET >=> routef "/funds/%s/rebalance/plans/%s/preview" (fun (fundId, planId) -> previewRebalancePlan repository fundId planId) GET >=> routef "/funds/%s/rebalance/executions" (getRebalanceExecutions repository) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 5000161..379c10d 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -389,6 +389,10 @@ type SipPlanWriteResult = | SipPlanFundNotFound | SipPlanInstrumentNotFound +type SipPlanStatusResult = + | SipPlanStatusChanged of SipPlanRecord + | SipPlanStatusNotFound + type SipExecutionOutcome = { TradeDate: DateOnly @@ -494,6 +498,10 @@ type RebalanceWriteResult = | RebalanceFundNotFound | RebalanceInstrumentNotFound +type RebalanceArchiveResult = + | RebalancePlanArchived of RebalancePlanRecord + | RebalanceArchiveNotFound + type RebalanceOrderOutcome = { InstrumentCode: string @@ -3347,6 +3355,54 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.SetSipPlanStatus(fundId: Guid, planId: Guid, status: string) : SipPlanStatusResult = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "sip-plan-status:%O" planId)) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findSipPlan connection (Some transaction) planId with + | Some plan when plan.FundId = fundId -> + if plan.Status = status then + transaction.Commit() + SipPlanStatusResult.SipPlanStatusChanged plan + else + use updateCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE sip_plans SET status = @status WHERE id = @plan_id" + + addParameter updateCommand "status" NpgsqlDbType.Text (box status) |> ignore + addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + updateCommand.ExecuteNonQuery() |> ignore + + let updated = findSipPlan connection (Some transaction) planId + transaction.Commit() + + match updated with + | Some updatedPlan -> SipPlanStatusResult.SipPlanStatusChanged updatedPlan + | None -> SipPlanStatusResult.SipPlanStatusNotFound + | _ -> + transaction.Rollback() + SipPlanStatusResult.SipPlanStatusNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + member _.CreateInvestmentPlan(idempotencyKey: string, fundId: Guid, command: InvestmentPlanCommand, ?anchorOverride: DateOnly) : InvestmentPlanWriteResult = if String.IsNullOrWhiteSpace idempotencyKey then InvestmentPlanWriteResult.InvestmentPlanInvalid "idempotency key cannot be empty" @@ -3838,6 +3894,53 @@ type FundRepository(connectionString: string) = reader.Close() plans |> Seq.toList |> List.map (rebalancePlanWithTargets connection None) + member _.ArchiveRebalancePlan(fundId: Guid, planId: Guid) : RebalanceArchiveResult = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "rebalance-archive:%O" planId)) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findRebalancePlan connection (Some transaction) planId with + | Some plan when plan.FundId = fundId -> + if plan.Status = "archived" then + transaction.Commit() + RebalanceArchiveResult.RebalancePlanArchived plan + else + use updateCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE rebalance_plans SET status = 'archived' WHERE id = @plan_id" + + addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + updateCommand.ExecuteNonQuery() |> ignore + + let updated = findRebalancePlan connection (Some transaction) planId + transaction.Commit() + + match updated with + | Some updatedPlan -> RebalanceArchiveResult.RebalancePlanArchived updatedPlan + | None -> RebalanceArchiveResult.RebalanceArchiveNotFound + | _ -> + transaction.Rollback() + RebalanceArchiveResult.RebalanceArchiveNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + member this.ExecuteRebalancePlan(planId: Guid) : Result<RebalanceExecutionResult, string> = use connection = new NpgsqlConnection(connectionString) connection.Open() @@ -3868,6 +3971,7 @@ type FundRepository(connectionString: string) = match plan with | None -> Error "rebalance plan was not found" + | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived" | Some plan -> let fundId = plan.FundId let runDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow @@ -4033,6 +4137,7 @@ type FundRepository(connectionString: string) = match findRebalancePlan connection None planId with | None -> Error "rebalance plan was not found" + | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived" | Some plan -> match this.GetFund plan.FundId with | None -> Error "fund was not found" diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 62bb20e..7dea8bb 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -1005,6 +1005,9 @@ module Api = [<Import("getRebalancePlans", "./src/api.js")>] let getRebalancePlans (token: string) (fundId: string) : JS.Promise<RawRebalancePlan array> = jsNative + [<Import("archiveRebalancePlan", "./src/api.js")>] + let archiveRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalancePlan> = jsNative + [<Import("executeRebalancePlan", "./src/api.js")>] let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawRebalanceExecution> = jsNative @@ -1020,6 +1023,12 @@ module Api = [<Import("getSipPlans", "./src/api.js")>] let getSipPlans (token: string) (fundId: string) : JS.Promise<RawSipPlan array> = jsNative + [<Import("pauseSipPlan", "./src/api.js")>] + let pauseSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawSipPlan> = jsNative + + [<Import("resumeSipPlan", "./src/api.js")>] + let resumeSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise<RawSipPlan> = jsNative + [<Import("getInvestmentPlans", "./src/api.js")>] let getInvestmentPlans (token: string) (fundId: string) : JS.Promise<RawInvestmentPlan array> = jsNative @@ -1411,11 +1420,15 @@ type Model = sipCreateSeq: int sipReadSeq: int sipInFlight: bool + sipStatusSeq: int + sipStatusInFlight: bool lastSipAttempt: SipAttempt option sipPlans: SipPlan list rebalTargets: RebalanceTargetDraft list rebalCreateSeq: int rebalReadSeq: int + rebalArchiveSeq: int + rebalArchiveInFlight: bool rebalInFlight: bool rebalExecuteSeq: int rebalExecuteInFlight: bool @@ -1548,6 +1561,10 @@ type Msg = | SipPlansReadRequested | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array | SipPlansReadFailed of requestId: int * message: string + | SipPlanPauseRequested of planId: string + | SipPlanResumeRequested of planId: string + | SipPlanStatusChanged of requestId: int * plan: RawSipPlan + | SipPlanStatusChangeFailed of requestId: int * message: string | RebalTargetCodeChanged of index: int * value: string | RebalTargetPercentChanged of index: int * value: string | RebalTargetAdded @@ -1558,6 +1575,9 @@ type Msg = | RebalancePlansReadRequested | RebalancePlansReadCompleted of requestId: int * plans: RawRebalancePlan array | RebalancePlansReadFailed of requestId: int * message: string + | RebalancePlanArchiveRequested of planId: string + | RebalancePlanArchived of requestId: int * plan: RawRebalancePlan + | RebalancePlanArchiveFailed of requestId: int * message: string | RebalancePreviewRequested of planId: string | RebalancePreviewCompleted of requestId: int * preview: RawRebalancePreview | RebalancePreviewFailed of requestId: int * message: string @@ -1705,11 +1725,15 @@ let init () = sipCreateSeq = 0 sipReadSeq = 0 sipInFlight = false + sipStatusSeq = 0 + sipStatusInFlight = false lastSipAttempt = None sipPlans = [] rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = 0 rebalReadSeq = 0 + rebalArchiveSeq = 0 + rebalArchiveInFlight = false rebalInFlight = false rebalExecuteSeq = 0 rebalExecuteInFlight = false @@ -1894,6 +1918,20 @@ let private readSipPlansCommand token fundId requestId = (fun plans -> SipPlansReadCompleted(requestId, plans)) (fun error -> SipPlansReadFailed(requestId, errorText error)) +let private pauseSipPlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.pauseSipPlan token fundId planId) + () + (fun plan -> SipPlanStatusChanged(requestId, plan)) + (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) + +let private resumeSipPlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.resumeSipPlan token fundId planId) + () + (fun plan -> SipPlanStatusChanged(requestId, plan)) + (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) + let private createRebalancePlanCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createRebalancePlan token fundId payload) @@ -1908,6 +1946,13 @@ let private readRebalancePlansCommand token fundId requestId = (fun plans -> RebalancePlansReadCompleted(requestId, plans)) (fun error -> RebalancePlansReadFailed(requestId, errorText error)) +let private archiveRebalancePlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.archiveRebalancePlan token fundId planId) + () + (fun plan -> RebalancePlanArchived(requestId, plan)) + (fun error -> RebalancePlanArchiveFailed(requestId, errorText error)) + let private executeRebalanceCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.executeRebalancePlan token fundId planId) @@ -2027,6 +2072,18 @@ let private runInvestmentPlansCommand token fundId requestId = (fun run -> InvestmentPlansRunCompleted(requestId, fundId, run)) (fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error)) +let private dispatchSipStatusChange model planId pause = + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.sipStatusInFlight -> + let requestId = model.sipStatusSeq + 1 + + { model with sipStatusSeq = requestId; sipStatusInFlight = true; error = None }, + (if pause then + pauseSipPlanCommand model.token fund.id planId requestId + else + resumeSipPlanCommand model.token fund.id planId requestId) + | _ -> model, Cmd.none + let update message model = match message with | TokenChanged token -> @@ -2834,6 +2891,28 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | SipPlanPauseRequested planId -> dispatchSipStatusChange model planId true + | SipPlanResumeRequested planId -> dispatchSipStatusChange model planId false + | SipPlanStatusChanged (requestId, plan) -> + if requestId = model.sipStatusSeq then + let decoded = Api.decodeSipPlan plan + + { + model with + sipStatusInFlight = false + sipPlans = + model.sipPlans + |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) + error = None + }, + Cmd.none + else + model, Cmd.none + | SipPlanStatusChangeFailed (requestId, message) -> + if requestId = model.sipStatusSeq then + { model with sipStatusInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalTargetCodeChanged (index, value) -> { model with @@ -2958,6 +3037,33 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | RebalancePlanArchiveRequested planId -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalArchiveInFlight -> + let requestId = model.rebalArchiveSeq + 1 + { model with rebalArchiveSeq = requestId; rebalArchiveInFlight = true; error = None }, + archiveRebalancePlanCommand model.token fund.id planId requestId + | _ -> model, Cmd.none + | RebalancePlanArchived (requestId, plan) -> + if requestId = model.rebalArchiveSeq then + let decoded = Api.decodeRebalancePlan plan + + { + model with + rebalArchiveInFlight = false + rebalPlans = + model.rebalPlans + |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) + error = None + }, + Cmd.none + else + model, Cmd.none + | RebalancePlanArchiveFailed (requestId, message) -> + if requestId = model.rebalArchiveSeq then + { model with rebalArchiveInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalancePreviewRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalPreviewInFlight -> @@ -4394,7 +4500,7 @@ let private sipFrequencyText (frequency: string) = elif frequency = "monthly" then "每月" else frequency -let private sipPlanRow (plan: SipPlan) = +let private sipPlanRow dispatch (plan: SipPlan) = Html.div [ prop.className "order-row" prop.children [ @@ -4418,7 +4524,31 @@ let private sipPlanRow (plan: SipPlan) = | None -> "最近执行 暂无" Html.span [ prop.className "order-cell"; prop.text lastExecution ] - Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ] + Html.span [ + prop.className "order-status" + prop.text ( + if plan.status = "active" then "进行中" + elif plan.status = "paused" then "已暂停" + else plan.status + ) + ] + Html.span [ + prop.className "order-cell sip-plan-actions" + prop.children [ + if plan.status = "paused" then + Html.button [ + prop.className "secondary-action sip-resume-action" + prop.onClick (fun _ -> dispatch (SipPlanResumeRequested plan.id)) + prop.text "恢复" + ] + else + Html.button [ + prop.className "secondary-action sip-pause-action" + prop.onClick (fun _ -> dispatch (SipPlanPauseRequested plan.id)) + prop.text "暂停" + ] + ] + ] ] ] @@ -4497,7 +4627,7 @@ let private sipPanel model dispatch = if List.isEmpty model.sipPlans then Html.p [ prop.className "hint"; prop.text "暂无定投计划" ] else - yield! (model.sipPlans |> List.map sipPlanRow) + yield! (model.sipPlans |> List.map (sipPlanRow dispatch)) ] ] Html.div [ @@ -4691,6 +4821,10 @@ let private rebalancePanel model dispatch = |> List.map (fun target -> sprintf "%s %s%%" target.instrumentCode target.targetPercent) |> String.concat " / ") ] + Html.span [ + prop.className "order-status" + prop.text (if plan.status = "archived" then "已归档" else "进行中") + ] (if plan.status = "active" then Html.span [ prop.className "order-actions" @@ -4707,6 +4841,12 @@ let private rebalancePanel model dispatch = prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id)) prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string) ] + Html.button [ + prop.className "secondary-action rebalance-archive-action" + prop.disabled model.rebalArchiveInFlight + prop.onClick (fun _ -> dispatch (RebalancePlanArchiveRequested plan.id)) + prop.text "归档" + ] ] ] else diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 031ff74..a03b97f 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -199,6 +199,18 @@ export function getSipPlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token); } +export function pauseSipPlan(token, fundId, planId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans/${encodeURIComponent(planId)}/pause`, token, { + method: "POST" + }); +} + +export function resumeSipPlan(token, fundId, planId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans/${encodeURIComponent(planId)}/resume`, token, { + method: "POST" + }); +} + export function getInvestmentPlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/investment-plans`, token); } @@ -227,6 +239,14 @@ export function getRebalancePlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans`, token); } +export function archiveRebalancePlan(token, fundId, planId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/archive`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}" + }); +} + export function executeRebalancePlan(token, fundId, planId) { return requestJson( `/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/execute`, |
